Experiments

About

Back

A Dynamic AF Shadow Function

June 2023

  • CSS

  • Javascript

  • UI Design

There's an illusion in design where shadows appear stronger the lighter the surface color is that's casting the shadow. A recent project got me thinking about the idea of creating a Css-in-JS shadow function where the shadow opacity is dynamically calculated based on the darkness of the surface color that's casting the shadow.

The function takes in surfaceColor and shadowColor arguments. The surfaceColor should be the background color of the element casting the shadow. The function will dynamically calculate an opacity to use for the layered shadow based on the contrast ratio of the surfaceColor: backgroundColor (assumed to be #fff in this case). The output is a visually balanced box-shadow.

Static Shadow:

Dynamic Shadow:

Shadow function code:

import { getContrast, rgba } from "polished";

 const dynamicShadowFn = (
  rgbShadowColor: string,
  surfaceColor: string
) => {
  const min = 0.05;
  const max = 0.15;
  const difference = max - min;
  let calculatedOpacity: number = min;

  // we want the opacity to be min if 1:1, and max if anything above 8:1
  const maxRatio = 8;
  const minRatio = 1;
  const ratioDiff = maxRatio - minRatio;

  let ratio = getContrast(surfaceColor, "#fff");
  if (ratio > maxRatio) ratio = maxRatio;

  const contrastCoefficient = (ratio - minRatio) / ratioDiff;

  calculatedOpacity =
    min + Number((contrastCoefficient * difference).toFixed(2));

  // we need to subtract 1 from the ratio var, and divide  by 7 to get contrast coefficient
  // Ie. If contrast = 4, then coefficient = 4-1 / 7 = .43
  // 7 = 1
  // 3.5 = 0.5
  // 0 = 0
  //  Contrast = 0.05 + (contrast coefficient x .10)
  // Ie. (0.05 + .5 x .1 ) = 10% opacities for a 4:1 color
  // Ie. (0.05 + .25 x .1 ) = 7.5% opacities for a 2:1 color
  // Ie. (0.05 + .125 x .1) = 6.25% opacities for a 1:1 color

  const shadowString = `0px 1px 0px -1px ${rgba(
    rgbShadowColor,
    calculatedOpacity
  )}, 0px 1px 2px -1px ${rgba(
    rgbShadowColor,
    calculatedOpacity
  )},  0px 3px 5px -1px ${rgba(
    rgbShadowColor,
    calculatedOpacity
  )}, 0px 7px 12px -2px ${rgba(rgbShadowColor, calculatedOpacity)}`;

  return shadowString;
};

Then, use it in your button. This is a basic CSS-in-JS example using injected inline styles:

import hexToRgb from "@mui/material";

const commonButtonStyles = {
  // other buttonStyles here..
}
const darkButtonStyles = {
  ...commonButtonStyles,
  boxShadow: dynamicShadowFn(hexToRgb(#364153), hexToRgb(#364153)),
  // ...other darkButton specific Styles
}

const lightButtonStyles = {
  ...commonButtonStyles,

  boxShadow: dynamicShadowFn(hexToRgb(#364153), hexToRgb(#F2F5F9)),
  // ...other lightButton specific Styles
}

const YourButton = (props) => {
  const styles = variant === "primary" ? darkButtonStyles() : lightButtonStyles()
  return <button style={styles}>{props.children}</button>
}

const YourComponent = () => {
 return (
   <>
      <YourButton variant="primary"/>
      <YourButton variant="secondary"/>
   </>
   )
}