├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── babel.config.js
├── benchmark
├── index.js
└── results
│ ├── benchmark.chart.html
│ └── benchmark.json
├── docs
├── demo.png
├── index-bundle.js
├── index.html
└── index.js
├── jest.json
├── lib
├── index.d.ts
├── index.js
├── index.js.map
├── index.mjs
└── index.mjs.map
├── package-lock.json
├── package.json
├── rollup.config.js
├── rollup.demo.config.js
├── src
├── bezier.js
├── index.test.js
└── index.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | coverage
2 | node_modules
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "trailingComma": "none",
5 | "printWidth": 120,
6 | "max-len": [
7 | "error",
8 | {
9 | "code": 100,
10 | "ignoreComments": true,
11 | "ignoreStrings": true,
12 | "ignoreTemplateLiterals": true
13 | }
14 | ],
15 | "htmlWhitespaceSensitivity": "ignore"
16 | }
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Thomas Strobl
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # smooth-shadow
2 |
3 | > Generate smooth, consistent and always-sexy box-shadows, no matter the size, ideal for design token generation.
4 |
5 | 
6 |
7 | [Demo](http://tom2strobl.github.io/smooth-shadow)
8 |
9 | As [Tobias](https://tobiasahlin.com/blog/layered-smooth-box-shadows/) already pointed out in 2019, a regular singular CSS `box-shadow` statement is as dull as it gets. Which is fine as long as you use it as a low-level API. And that's what we do by layering multiple box-shadows in a way to make them look a lot more physically realistic. No, the shadows are not *really* raytraced, but we borrow some concepts.
10 |
11 | Many great people tackled the issue from Tobias Sahlin, to Philipp Brumm and Josh W Comeau, but none of them offered their solution as OSS-code. The previous attempts at libraries of other folks give you lots of settings like direct alpha, blur and spread settings, but on the flipside that means and you have to find good values for small and large shadows yourself.
12 |
13 | `smooth-shadow` is largely opinionated in what makes a great smooth shadow and only offers you relevant options for your usecase:
14 |
15 | - Distance (basically the elevation/size of the shadow)
16 | - Intensity (which should vary depending on environment color)
17 | - Sharpness (which - although largely opinionated - leaves a little room for the projects style)
18 | - Color (as real cast shadows pick up the color of reflected light from the surface they are cast on)
19 | - Light Position (to determine in which direction the shadow should cast)
20 |
21 | And as always:
22 |
23 | - *zero* dependencies
24 | - *fully typed* in typescript
25 | - *small* footprint (2.3kb minified)
26 | - CommonJS bundle, .mjs bundle, .d.ts bundle + type-checking & Source maps
27 |
28 | ## Installation
29 |
30 | `npm i smooth-shadow` or `yarn add smooth-shadow`
31 |
32 | ## Usage
33 |
34 | ```typescript
35 | import { getSmoothShadow } from 'smooth-shadow'
36 |
37 | // it returns a box-shadow css statement ready to use
38 | const boxShadow = getSmoothShadow()
39 |
40 | // eg. on a jsx component
41 |
42 |
43 | // or eg. in a styled component
44 | styled.div`box-shadow: ${boxShadow}`
45 |
46 | // or eg. even better in a styled-component (or any other framework) theme
47 | const cardShadow = getSmoothShadow({ distance: 100 })
48 | const theme = { cardShadow }
49 |
50 | styled.div`box-shadow: ${({ theme }) => theme.cardShadow};`
51 |
52 | // or eg. even better better as a shadow factory
53 | const appShadow = (distance: number) => getSmoothShadow({
54 | distance,
55 | intensity: 0.2,
56 | sharpness: 0.7,
57 | color: [69,69,69],
58 | lightPosition: [0, -0.5]
59 | })
60 | const cardShadowSmall = appShadow(50)
61 | const cardShadowBig = appShadow(200)
62 | const theme = { cardShadowSmall, cardShadowBig }
63 |
64 | const SmallCard = styled.div`box-shadow: ${({ theme }) => theme.cardShadowSmall};`
65 | const BigCard = styled.div`box-shadow: ${({ theme }) => theme.cardShadowBig};`
66 | ```
67 |
68 | ```typescript
69 | getSmoothShadow({
70 | // the distance the shadow travels, larger distance = larger shadow
71 | distance?: number, // default 100 (between 0 & 1000)
72 | // sort of your "opacity" parameter if you will
73 | intensity?: number, // default 0.5 (between 0 & 1)
74 | // low values result in a more mellow shadow, high values in a more crispy experience
75 | sharpness?: number, // default 0.5 (between 0 & 1)
76 | // on colored backgrounds you should tint your shadows for more sexiness, totally optional though
77 | color?: [number, number, number], // default [0, 0, 0] ([0-255, 0-255, 0-255])
78 | // position of the lighton x/y axis. 0 is the center, -1 left/top, 1 right/bottom
79 | lightPosition?: [number, number] // [-1 - 1, -1 - 1], where 0 is the center
80 | }) => string
81 | ```
82 |
83 | For the code example of the screenshot / the [Demo](http://tom2strobl.github.io/smooth-shadow), check out `/docs/index.js`.
84 |
85 | ## How it works
86 |
87 | Depending on `distance` a good amount of layers (more layers means softer result but less performance, so we try to find the best tradeoff) is determined and then through linear interpolation and carefully self-crafted bezier-easing-functions in combination with the `sharpness` and `intensity` arguments realistic looking results are plotted. `color` and `lightPosition` are rather straightforward.
88 |
89 | ## Performance
90 |
91 | With `179 818 ops/s, ±1.92%` it's considerably fast. That being said I would not suggest you to try animate generated shadow-values directly as there can be up to 24 layers of shadows and animating them directly is costly. Instead animate the opacity of an element that has said shadow. [Read more](https://tobiasahlin.com/blog/how-to-animate-box-shadow/)
92 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript', '@babel/preset-react']
3 | }
4 |
--------------------------------------------------------------------------------
/benchmark/index.js:
--------------------------------------------------------------------------------
1 | const b = require('benny')
2 | const { getSmoothShadow } = require('../lib')
3 |
4 | b.suite(
5 | 'getSmoothShadow Benchmark',
6 |
7 | b.add('getSmoothShadow', () => {
8 | return getSmoothShadow()
9 | }),
10 |
11 | b.cycle(),
12 | b.complete(),
13 | b.save({ file: 'benchmark', version: '1.0.0' }),
14 | b.save({ file: 'benchmark', format: 'chart.html' })
15 | )
16 |
--------------------------------------------------------------------------------
/benchmark/results/benchmark.chart.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | getSmoothShadow Benchmark
9 |
28 |
29 |
30 |
31 |
32 |
33 |
115 |
116 |
--------------------------------------------------------------------------------
/benchmark/results/benchmark.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "getSmoothShadow Benchmark",
3 | "date": "2022-10-11T20:13:35.205Z",
4 | "version": "1.0.0",
5 | "results": [
6 | {
7 | "name": "getSmoothShadow",
8 | "ops": 179818,
9 | "margin": 1.92,
10 | "percentSlower": 0
11 | }
12 | ],
13 | "fastest": {
14 | "name": "getSmoothShadow",
15 | "index": 0
16 | },
17 | "slowest": {
18 | "name": "getSmoothShadow",
19 | "index": 0
20 | }
21 | }
--------------------------------------------------------------------------------
/docs/demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tom2strobl/smooth-shadow/4770c25b8641771fbb92e56d29142c9169a546e1/docs/demo.png
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Smooth Shadow - tom2strobl
10 |
11 |
12 |
13 |
14 | You need to enable JavaScript to run this app.
15 |
16 |
17 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/docs/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { getSmoothShadow } from '../lib/index.mjs'
3 | import * as ReactDOMClient from 'react-dom/client'
4 |
5 | function App() {
6 | const orangeIntensity = 0.45
7 | const greyIntensity = 0.2
8 | const sharpnessSharp = 0.9
9 | const sharpnessSoft = 0.1
10 | const orangeShadow = [192, 50, 0]
11 | const rightLight = [0.5, -0.5]
12 | const boxBaseProps = {
13 | height: '10vh',
14 | width: '10vw',
15 | backgroundColor: 'white',
16 | borderRadius: 6,
17 | padding: 20,
18 | display: 'grid',
19 | alignContent: 'space-around'
20 | }
21 | const columnStyle = {
22 | display: 'grid',
23 | gridTemplateColumns: 'auto auto',
24 | alignContent: 'space-evenly',
25 | justifyContent: 'space-evenly'
26 | }
27 | const label = (str, num) => (
28 |
29 |
{str}
30 |
{num}
31 |
32 | )
33 | const box = (...args) => (
34 |
46 | {label('distance', args[0])}
47 | {label('intensity', args[1])}
48 | {label('sharpness', args[2])}
49 | {args[3] && label('color', `(${args[3].join(',')})`)}
50 | {args[4] && label('light', `(${args[4].join(',')})`)}
51 |
52 | )
53 | return (
54 |
65 |
66 | {box(50, orangeIntensity, sharpnessSharp, orangeShadow)}
67 | {box(50, orangeIntensity, sharpnessSoft, orangeShadow)}
68 | {box(100, orangeIntensity, sharpnessSharp, orangeShadow)}
69 | {box(100, orangeIntensity, sharpnessSoft, orangeShadow, rightLight)}
70 | {box(500, orangeIntensity, sharpnessSharp, orangeShadow)}
71 | {box(500, orangeIntensity, sharpnessSoft, orangeShadow)}
72 |
73 |
74 | {box(50, greyIntensity, sharpnessSharp)}
75 | {box(50, greyIntensity, sharpnessSoft)}
76 | {box(100, greyIntensity, sharpnessSharp, [0, 0, 0], rightLight)}
77 | {box(100, greyIntensity, sharpnessSoft, [0, 0, 0])}
78 | {box(500, greyIntensity, sharpnessSharp)}
79 | {box(500, greyIntensity, sharpnessSoft)}
80 |
81 |
82 | )
83 | }
84 |
85 | const rootElement = document.getElementById('root')
86 | const root = ReactDOMClient.createRoot(rootElement)
87 |
88 | root.render(
89 |
90 |
91 |
92 | )
93 |
--------------------------------------------------------------------------------
/jest.json:
--------------------------------------------------------------------------------
1 | {
2 | "testEnvironment": "node",
3 | "modulePaths": [
4 | "src",
5 | "/node_modules/"
6 | ],
7 | "coverageThreshold": {
8 | "global": {
9 | "branches": 100,
10 | "functions": 100,
11 | "lines": 100,
12 | "statements": 100
13 | }
14 | },
15 | "collectCoverageFrom" : [
16 | "src/**/*.ts"
17 | ]
18 | }
--------------------------------------------------------------------------------
/lib/index.d.ts:
--------------------------------------------------------------------------------
1 | declare type GetSmoothShadowOptions = {
2 | distance?: number;
3 | intensity?: number;
4 | sharpness?: number;
5 | color?: [number, number, number];
6 | lightPosition?: [number, number];
7 | };
8 | declare const getSmoothShadow: (options: GetSmoothShadowOptions) => string;
9 |
10 | export { getSmoothShadow };
11 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | "use strict";Object.defineProperty(exports,"__esModule",{value:!0});const NEWTON_ITERATIONS=4,NEWTON_MIN_SLOPE=.001,SUBDIVISION_PRECISION=1e-7,SUBDIVISION_MAX_ITERATIONS=10,kSplineTableSize=11,kSampleStepSize=1/(kSplineTableSize-1),float32ArraySupported="function"==typeof Float32Array;function A(e,r){return 1-3*r+3*e}function B(e,r){return 3*r-6*e}function C(e){return 3*e}function calcBezier(e,r,n){return((A(r,n)*e+B(r,n))*e+C(r))*e}function getSlope(e,r,n){return 3*A(r,n)*e*e+2*B(r,n)*e+C(r)}function binarySubdivide(e,r,n,t,i){var o;let a,l=0;for(;0<(o=calcBezier(a=r+(n-r)/2,t,i)-e)?n=a:r=a,Math.abs(o)>SUBDIVISION_PRECISION&&++l=NEWTON_MIN_SLOPE?newtonRaphsonIterate(e,i,a,l):0===o?i:binarySubdivide(e,r,r+kSampleStepSize,a,l)}(e),r,n)}}const lerp=(e,r,n)=>e*(1-n)+r*n,clamp=(e,r=0,n=1)=>Math.min(n,Math.max(r,e)),invlerp=(e,r,n)=>clamp((n-e)/(r-e)),roundPixel=e=>Math.round(10*e)/10,roundTransparency=e=>Math.round(1e3*e)/1e3,getSmoothShadow=e=>{var r={distance:100,intensity:.4,sharpness:.7,color:[0,0,0],lightPosition:[-.35,-.5]},n=e?.distance||r.distance,t=e?.intensity||r.intensity,i=e?.sharpness||r.sharpness;const o=e?.color||r.color;var e=e?.lightPosition||r.lightPosition,r=clamp(2*n,0,2e3),n=clamp(t,0,1),t=clamp(i,0,1),i=invlerp(1,2e3,r),a=bezier(.25,1-i,.5,1);const l=Math.round(24*a(i)),S=bezier(0,.3,0,.06)(i)/i*6.5/24*n,p=bezier(0,1,.8,.5),c=r*(-1*e[0]),u=r*(-1*e[1]);a=lerp(200,500,i);const s=lerp(100,a,t);n=bezier(1,0,1,0),r=lerp(0,2,n(1-t));const I=bezier(1,r,1,r);e=lerp(0,.075,1-t);const f=bezier(1,e,1,e);return Array.from(Array(l)).map((e,r)=>{var n=p(r/l),t=f(r/l),r=I(r/l),i=roundPixel(c*t),t=roundPixel(u*t),r=roundPixel(s*r),n=roundTransparency(S*n);return`${i}px ${t}px ${r}px rgba(${o[0]},${o[1]},${o[2]},${n})`}).join(", ")};exports.getSmoothShadow=getSmoothShadow;
2 | //# sourceMappingURL=index.js.map
3 |
--------------------------------------------------------------------------------
/lib/index.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"file":"index.js","sources":["../src/bezier.js","../src/index.ts"],"sourcesContent":["/* eslint-disable yoda */\n/* eslint-disable no-plusplus */\n/**\n * https://github.com/gre/bezier-easing\n * BezierEasing - use bezier curve for transition easing function\n * by Gaëtan Renaudeau 2014 - 2015 – MIT License\n */\n\n// These values are established by empiricism with tests (tradeoff: performance VS precision)\nconst NEWTON_ITERATIONS = 4\nconst NEWTON_MIN_SLOPE = 0.001\nconst SUBDIVISION_PRECISION = 0.0000001\nconst SUBDIVISION_MAX_ITERATIONS = 10\n\nconst kSplineTableSize = 11\nconst kSampleStepSize = 1.0 / (kSplineTableSize - 1.0)\n\nconst float32ArraySupported = typeof Float32Array === 'function'\n\nfunction A(aA1, aA2) {\n return 1.0 - 3.0 * aA2 + 3.0 * aA1\n}\nfunction B(aA1, aA2) {\n return 3.0 * aA2 - 6.0 * aA1\n}\nfunction C(aA1) {\n return 3.0 * aA1\n}\n\n// Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2.\nfunction calcBezier(aT, aA1, aA2) {\n return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT\n}\n\n// Returns dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2.\nfunction getSlope(aT, aA1, aA2) {\n return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1)\n}\n\nfunction binarySubdivide(aX, aA, aB, mX1, mX2) {\n let currentX\n let currentT\n let i = 0\n do {\n currentT = aA + (aB - aA) / 2.0\n currentX = calcBezier(currentT, mX1, mX2) - aX\n if (currentX > 0.0) {\n aB = currentT\n } else {\n aA = currentT\n }\n } while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS)\n return currentT\n}\n\nfunction newtonRaphsonIterate(aX, aGuessT, mX1, mX2) {\n for (let i = 0; i < NEWTON_ITERATIONS; ++i) {\n const currentSlope = getSlope(aGuessT, mX1, mX2)\n if (currentSlope === 0.0) {\n return aGuessT\n }\n const currentX = calcBezier(aGuessT, mX1, mX2) - aX\n aGuessT -= currentX / currentSlope\n }\n return aGuessT\n}\n\nfunction LinearEasing(x) {\n return x\n}\n\nexport default function bezier(mX1, mY1, mX2, mY2) {\n if (!(0 <= mX1 && mX1 <= 1 && 0 <= mX2 && mX2 <= 1)) {\n throw new Error('bezier x values must be in [0, 1] range')\n }\n\n if (mX1 === mY1 && mX2 === mY2) {\n return LinearEasing\n }\n\n // Precompute samples table\n const sampleValues = float32ArraySupported ? new Float32Array(kSplineTableSize) : new Array(kSplineTableSize)\n for (let i = 0; i < kSplineTableSize; ++i) {\n sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2)\n }\n\n function getTForX(aX) {\n let intervalStart = 0.0\n let currentSample = 1\n const lastSample = kSplineTableSize - 1\n\n for (; currentSample !== lastSample && sampleValues[currentSample] <= aX; ++currentSample) {\n intervalStart += kSampleStepSize\n }\n --currentSample\n\n // Interpolate to provide an initial guess for t\n const dist = (aX - sampleValues[currentSample]) / (sampleValues[currentSample + 1] - sampleValues[currentSample])\n const guessForT = intervalStart + dist * kSampleStepSize\n\n const initialSlope = getSlope(guessForT, mX1, mX2)\n if (initialSlope >= NEWTON_MIN_SLOPE) {\n return newtonRaphsonIterate(aX, guessForT, mX1, mX2)\n } else if (initialSlope === 0.0) {\n return guessForT\n } else {\n return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2)\n }\n }\n\n return function BezierEasing(x) {\n // Because JavaScript number are imprecise, we should guarantee the extremes are right.\n if (x === 0 || x === 1) {\n return x\n }\n return calcBezier(getTForX(x), mY1, mY2)\n }\n}\n","import BezierEasing from './bezier'\n\nconst lerp = (x: number, y: number, a: number) => x * (1 - a) + y * a\nconst clamp = (a: number, min = 0, max = 1) => Math.min(max, Math.max(min, a))\nconst invlerp = (x: number, y: number, a: number) => clamp((a - x) / (y - x))\n\nconst roundPixel = (num: number): number => Math.round(num * 10) / 10\nconst roundTransparency = (num: number): number => Math.round(num * 1000) / 1000\n\ntype GetSmoothShadowOptions = {\n distance?: number // 1-1000\n intensity?: number // 0-1\n sharpness?: number // 0-1\n color?: [number, number, number] // [0-255, 0-255, 0-255]\n lightPosition?: [number, number] // [-1 - 1, -1 - 1], where 0 is the center\n}\n\nexport const getSmoothShadow = (options: GetSmoothShadowOptions): string => {\n // establish good defaults\n const defaults = {\n distance: 100,\n intensity: 0.4,\n sharpness: 0.7,\n color: [0, 0, 0],\n lightPosition: [-0.35, -0.5]\n }\n // can't do { ...defaults, ...options } because if a value in options is set to undefined, it will override a default value\n const distance = options?.distance || defaults.distance\n const intensity = options?.intensity || defaults.intensity\n const sharpness = options?.sharpness || defaults.sharpness\n const color = options?.color || defaults.color\n const lightPosition = options?.lightPosition || defaults.lightPosition\n // in terms of performance it makes sense to limit a maximum\n const maxDistance = 2000\n const maxLayers = 24\n // clamp user values so the result doesnt go apeshit\n const cdistance = clamp(distance * 2, 0, maxDistance)\n const cintensity = clamp(intensity, 0, 1)\n const csharpness = clamp(sharpness, 0, 1)\n // the fractional distance to the maximum 0-1\n const interpolatedDistance = invlerp(1, maxDistance, cdistance)\n // the more distance, the more layers\n const amountEasing = BezierEasing(0.25, 1 - interpolatedDistance, 0.5, 1)\n // dont forget to round since we can only handle an integer amount of layers\n const amountLayers = Math.round(maxLayers * amountEasing(interpolatedDistance))\n // no reason to make this dynamic as it always looks good\n const distanceTransparency = BezierEasing(0, 0.3, 0, 0.06)\n // we want short distances to have enough opacity to look good\n const transparencyBase = (distanceTransparency(interpolatedDistance) / interpolatedDistance) * 6.5\n // now factor in intensity\n const finalTransparency = (transparencyBase / maxLayers) * cintensity\n // no reason to make this dynamic as it always looks good\n const transparencyEasing = BezierEasing(0, 1, 0.8, 0.5)\n // take light position into consideration\n const distanceX = cdistance * (lightPosition[0] * -1)\n const distanceY = cdistance * (lightPosition[1] * -1)\n // maxBlur scales with distance\n const maxBlur = lerp(200, 500, interpolatedDistance)\n // factor in sharpness to base blur value\n const finalBlur = lerp(100, maxBlur, csharpness)\n // this one's a little tricky, but for good looks we want multiple layers of ease\n const blurSharpnessEase = BezierEasing(1, 0, 1, 0)\n const easingBlurSharpness = lerp(0, 2, blurSharpnessEase(1 - csharpness))\n const blurEasing = BezierEasing(1, easingBlurSharpness, 1, easingBlurSharpness)\n // closer distances need to be paired with sharpness slightly differently to look good\n const easingSharpness = lerp(0, 0.075, 1 - csharpness)\n const distanceEasing = BezierEasing(1, easingSharpness, 1, easingSharpness)\n // iterate of the all layers and generate the final box-shadow string\n return Array.from(Array(amountLayers))\n .map((_, i) => {\n const transparencyCoeff = transparencyEasing(i / amountLayers)\n const distanceCoeff = distanceEasing(i / amountLayers)\n const blurCoeff = blurEasing(i / amountLayers)\n const x = roundPixel(distanceX * distanceCoeff)\n const y = roundPixel(distanceY * distanceCoeff)\n const b = roundPixel(finalBlur * blurCoeff)\n const t = roundTransparency(finalTransparency * transparencyCoeff)\n return `${x}px ${y}px ${b}px rgba(${color[0]},${color[1]},${color[2]},${t})`\n })\n .join(', ')\n}\n"],"names":["NEWTON_ITERATIONS","NEWTON_MIN_SLOPE","SUBDIVISION_PRECISION","SUBDIVISION_MAX_ITERATIONS","kSplineTableSize","kSampleStepSize","float32ArraySupported","Float32Array","A","aA1","aA2","B","C","calcBezier","aT","getSlope","binarySubdivide","aX","aA","aB","mX1","mX2","let","currentX","currentT","i","Math","abs","newtonRaphsonIterate","aGuessT","currentSlope","LinearEasing","x","bezier","mY1","mY2","Error","sampleValues","Array","intervalStart","currentSample","lastSample","dist","initialSlope","guessForT","lerp","y","a","clamp","min","max","invlerp","roundPixel","round","num","roundTransparency","getSmoothShadow","defaults","distance","intensity","sharpness","color","lightPosition","options","cdistance","cintensity","csharpness","interpolatedDistance","amountEasing","BezierEasing","amountLayers","finalTransparency","transparencyEasing","distanceX","distanceY","maxBlur","finalBlur","blurSharpnessEase","easingBlurSharpness","blurEasing","easingSharpness","distanceEasing","from","map","_","transparencyCoeff","distanceCoeff","blurCoeff","b","t","join"],"mappings":"oEASA,MAAMA,kBAAoB,EACpBC,iBAAmB,KACnBC,sBAAwB,KACxBC,2BAA6B,GAE7BC,iBAAmB,GACnBC,gBAAkB,GAAOD,iBAAmB,GAE5CE,sBAAgD,YAAxB,OAAOC,aAErC,SAASC,EAAEC,EAAKC,GACP,OAAA,EAAM,EAAMA,EAAM,EAAMD,CACjC,CACA,SAASE,EAAEF,EAAKC,GACP,OAAA,EAAMA,EAAM,EAAMD,CAC3B,CACA,SAASG,EAAEH,GACT,OAAO,EAAMA,CACf,CAGA,SAASI,WAAWC,EAAIL,EAAKC,GAC3B,QAASF,EAAEC,EAAKC,CAAG,EAAII,EAAKH,EAAEF,EAAKC,CAAG,GAAKI,EAAKF,EAAEH,CAAG,GAAKK,CAC5D,CAGA,SAASC,SAASD,EAAIL,EAAKC,GACzB,OAAO,EAAMF,EAAEC,EAAKC,CAAG,EAAII,EAAKA,EAAK,EAAMH,EAAEF,EAAKC,CAAG,EAAII,EAAKF,EAAEH,CAAG,CACrE,CAEA,SAASO,gBAAgBC,EAAIC,EAAIC,EAAIC,EAAKC,GACpCC,IAAAC,EACAD,IAAAE,EACAC,EAAI,EACL,KAGc,GADfF,EAAWV,WADAW,EAAAN,GAAMC,EAAKD,GAAM,EACIE,EAAKC,CAAG,EAAIJ,GAErCE,EAAAK,EAEAN,EAAAM,EAEAE,KAAKC,IAAIJ,CAAQ,EAAIrB,uBAAyB,EAAEuB,EAAItB,6BACtD,OAAAqB,CACT,CAEA,SAASI,qBAAqBX,EAAIY,EAAST,EAAKC,GAC9C,IAAAC,IAASG,EAAI,EAAGA,EAAIzB,kBAAmB,EAAEyB,EAAG,CAC1C,IAAMK,EAAef,SAASc,EAAST,EAAKC,CAAG,EAC/C,GAAqB,IAAjBS,EACK,OAAAD,EAGTA,IADiBhB,WAAWgB,EAAST,EAAKC,CAAG,EAAIJ,GAC3Ba,CACxB,CACO,OAAAD,CACT,CAEA,SAASE,aAAaC,GACb,OAAAA,CACT,CAEA,SAAwBC,OAAOb,EAAKc,EAAKb,EAAKc,GACxC,GAAA,EAAE,GAAKf,GAAOA,GAAO,GAAK,GAAKC,GAAOA,GAAO,GACzC,MAAA,IAAIe,MAAM,yCAAyC,EAGvD,GAAAhB,IAAQc,GAAOb,IAAQc,EAClB,OAAAJ,aAIH,MAAAM,EAAuC,IAAxB/B,sBAA4BC,aAAqC+B,OAAxBlC,gBAAgB,EAC9E,IAAAkB,IAASG,EAAI,EAAGA,EAAIrB,iBAAkB,EAAEqB,EACtCY,EAAaZ,GAAKZ,WAAWY,EAAIpB,gBAAiBe,EAAKC,CAAG,EA2BrD,OAAA,SAAsBW,GAEvB,OAAM,IAANA,GAAiB,IAANA,EACNA,EAEFnB,WA7BT,SAAkBI,GAChBK,IAAIiB,EAAgB,EAChBC,EAAgB,EAGpB,IAFA,IAAMC,EAAarC,iBAAmB,EAE/BoC,IAAkBC,GAAcJ,EAAaG,IAAkBvB,EAAI,EAAEuB,EACzDD,GAAAlC,gBAEjB,EAAAmC,EAGF,IAAME,GAAQzB,EAAKoB,EAAaG,KAAmBH,EAAaG,EAAgB,GAAKH,EAAaG,IAG5FG,EAAe5B,SAAS6B,EAFZL,EAAgBG,EAAOrC,gBAEAe,EAAKC,CAAG,EACjD,OAAIsB,GAAgB1C,iBACX2B,qBAAqBX,EAAI2B,EAAWxB,EAAKC,CAAG,EACzB,IAAjBsB,EACFC,EAEA5B,gBAAgBC,EAAIsB,EAAeA,EAAgBlC,gBAAiBe,EAAKC,CAAG,CAEvF,EAO6BW,CAAC,EAAGE,EAAKC,CAAG,CAAA,CAE3C,CCnHA,MAAMU,KAAO,CAACb,EAAWc,EAAWC,IAAcf,GAAK,EAAIe,GAAKD,EAAIC,EAC9DC,MAAQ,CAACD,EAAWE,EAAM,EAAGC,EAAM,IAAMxB,KAAKuB,IAAIC,EAAKxB,KAAKwB,IAAID,EAAKF,CAAC,CAAC,EACvEI,QAAU,CAACnB,EAAWc,EAAWC,IAAcC,OAAOD,EAAIf,IAAMc,EAAId,EAAE,EAEtEoB,WAAa,GAAyB1B,KAAK2B,MAAY,GAANC,CAAQ,EAAI,GAC7DC,kBAAoB,GAAyB7B,KAAK2B,MAAY,IAANC,CAAU,EAAI,IAU/DE,gBAAkB,IAE7B,IAAMC,EAAW,CACfC,SAAU,IACVC,UAAW,GACXC,UAAW,GACXC,MAAO,CAAC,EAAG,EAAG,GACdC,cAAe,CAAC,CAAA,IAAO,CAAI,GAAA,EAGvBJ,EAAWK,GAASL,UAAYD,EAASC,SACzCC,EAAYI,GAASJ,WAAaF,EAASE,UAC3CC,EAAYG,GAASH,WAAaH,EAASG,UAC3C,MAAAC,EAAQE,GAASF,OAASJ,EAASI,MACnC,IAAAC,EAAgBC,GAASD,eAAiBL,EAASK,cAKnDE,EAAYhB,MAAiB,EAAXU,EAAc,EAHlB,GAGgC,EAC9CO,EAAajB,MAAMW,EAAW,EAAG,CAAC,EAClCO,EAAalB,MAAMY,EAAW,EAAG,CAAC,EAElCO,EAAuBhB,QAAQ,EAPjB,IAOiCa,CAAS,EAExDI,EAAeC,OAAa,IAAM,EAAIF,EAAsB,GAAK,CAAC,EAExE,MAAMG,EAAe5C,KAAK2B,MAVR,GAU0Be,EAAaD,CAAoB,CAAC,EAMxEI,EAJuBF,OAAa,EAAG,GAAK,EAAG,GAAI,EAEVF,CAAoB,EAAIA,EAAwB,IAd7E,GAgByCF,EAErDO,EAAqBH,OAAa,EAAG,EAAG,GAAK,EAAG,EAEhDI,EAAYT,GAAgC,CAAA,EAAnBF,EAAc,IACvCY,EAAYV,GAAgC,CAAA,EAAnBF,EAAc,IAEvCa,EAAU9B,KAAK,IAAK,IAAKsB,CAAoB,EAEnD,MAAMS,EAAY/B,KAAK,IAAK8B,EAAST,CAAU,EAEzCW,EAAoBR,OAAa,EAAG,EAAG,EAAG,CAAC,EAC3CS,EAAsBjC,KAAK,EAAG,EAAGgC,EAAkB,EAAIX,CAAU,CAAC,EACxE,MAAMa,EAAaV,OAAa,EAAGS,EAAqB,EAAGA,CAAmB,EAExEE,EAAkBnC,KAAK,EAAG,KAAO,EAAIqB,CAAU,EACrD,MAAMe,EAAiBZ,OAAa,EAAGW,EAAiB,EAAGA,CAAe,EAEnE,OAAA1C,MAAM4C,KAAK5C,MAAMgC,CAAY,CAAC,EAClCa,IAAI,CAACC,EAAG3D,KACD,IAAA4D,EAAoBb,EAAmB/C,EAAI6C,CAAY,EACvDgB,EAAgBL,EAAexD,EAAI6C,CAAY,EAC/CiB,EAAYR,EAAWtD,EAAI6C,CAAY,EACvCtC,EAAIoB,WAAWqB,EAAYa,CAAa,EACxCxC,EAAIM,WAAWsB,EAAYY,CAAa,EACxCE,EAAIpC,WAAWwB,EAAYW,CAAS,EACpCE,EAAIlC,kBAAkBgB,EAAoBc,CAAiB,EAC1D,SAAGrD,OAAOc,OAAO0C,YAAY3B,EAAM,MAAMA,EAAM,MAAMA,EAAM,MAAM4B,IAAA,CACzE,EACAC,KAAK,IAAI,CACd"}
--------------------------------------------------------------------------------
/lib/index.mjs:
--------------------------------------------------------------------------------
1 | const NEWTON_ITERATIONS=4,NEWTON_MIN_SLOPE=.001,SUBDIVISION_PRECISION=1e-7,SUBDIVISION_MAX_ITERATIONS=10,kSplineTableSize=11,kSampleStepSize=1/(kSplineTableSize-1),float32ArraySupported="function"==typeof Float32Array;function A(e,r){return 1-3*r+3*e}function B(e,r){return 3*r-6*e}function C(e){return 3*e}function calcBezier(e,r,n){return((A(r,n)*e+B(r,n))*e+C(r))*e}function getSlope(e,r,n){return 3*A(r,n)*e*e+2*B(r,n)*e+C(r)}function binarySubdivide(e,r,n,t,i){var a;let o,l=0;for(;0<(a=calcBezier(o=r+(n-r)/2,t,i)-e)?n=o:r=o,Math.abs(a)>SUBDIVISION_PRECISION&&++l=NEWTON_MIN_SLOPE?newtonRaphsonIterate(e,i,o,l):0===a?i:binarySubdivide(e,r,r+kSampleStepSize,o,l)}(e),r,n)}}const lerp=(e,r,n)=>e*(1-n)+r*n,clamp=(e,r=0,n=1)=>Math.min(n,Math.max(r,e)),invlerp=(e,r,n)=>clamp((n-e)/(r-e)),roundPixel=e=>Math.round(10*e)/10,roundTransparency=e=>Math.round(1e3*e)/1e3,getSmoothShadow=e=>{var r={distance:100,intensity:.4,sharpness:.7,color:[0,0,0],lightPosition:[-.35,-.5]},n=e?.distance||r.distance,t=e?.intensity||r.intensity,i=e?.sharpness||r.sharpness;const a=e?.color||r.color;var e=e?.lightPosition||r.lightPosition,r=clamp(2*n,0,2e3),n=clamp(t,0,1),t=clamp(i,0,1),i=invlerp(1,2e3,r),o=bezier(.25,1-i,.5,1);const l=Math.round(24*o(i)),S=bezier(0,.3,0,.06)(i)/i*6.5/24*n,p=bezier(0,1,.8,.5),c=r*(-1*e[0]),u=r*(-1*e[1]);o=lerp(200,500,i);const s=lerp(100,o,t);n=bezier(1,0,1,0),r=lerp(0,2,n(1-t));const I=bezier(1,r,1,r);e=lerp(0,.075,1-t);const f=bezier(1,e,1,e);return Array.from(Array(l)).map((e,r)=>{var n=p(r/l),t=f(r/l),r=I(r/l),i=roundPixel(c*t),t=roundPixel(u*t),r=roundPixel(s*r),n=roundTransparency(S*n);return`${i}px ${t}px ${r}px rgba(${a[0]},${a[1]},${a[2]},${n})`}).join(", ")};export{getSmoothShadow};
2 | //# sourceMappingURL=index.mjs.map
3 |
--------------------------------------------------------------------------------
/lib/index.mjs.map:
--------------------------------------------------------------------------------
1 | {"version":3,"file":"index.mjs","sources":["../src/bezier.js","../src/index.ts"],"sourcesContent":["/* eslint-disable yoda */\n/* eslint-disable no-plusplus */\n/**\n * https://github.com/gre/bezier-easing\n * BezierEasing - use bezier curve for transition easing function\n * by Gaëtan Renaudeau 2014 - 2015 – MIT License\n */\n\n// These values are established by empiricism with tests (tradeoff: performance VS precision)\nconst NEWTON_ITERATIONS = 4\nconst NEWTON_MIN_SLOPE = 0.001\nconst SUBDIVISION_PRECISION = 0.0000001\nconst SUBDIVISION_MAX_ITERATIONS = 10\n\nconst kSplineTableSize = 11\nconst kSampleStepSize = 1.0 / (kSplineTableSize - 1.0)\n\nconst float32ArraySupported = typeof Float32Array === 'function'\n\nfunction A(aA1, aA2) {\n return 1.0 - 3.0 * aA2 + 3.0 * aA1\n}\nfunction B(aA1, aA2) {\n return 3.0 * aA2 - 6.0 * aA1\n}\nfunction C(aA1) {\n return 3.0 * aA1\n}\n\n// Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2.\nfunction calcBezier(aT, aA1, aA2) {\n return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT\n}\n\n// Returns dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2.\nfunction getSlope(aT, aA1, aA2) {\n return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1)\n}\n\nfunction binarySubdivide(aX, aA, aB, mX1, mX2) {\n let currentX\n let currentT\n let i = 0\n do {\n currentT = aA + (aB - aA) / 2.0\n currentX = calcBezier(currentT, mX1, mX2) - aX\n if (currentX > 0.0) {\n aB = currentT\n } else {\n aA = currentT\n }\n } while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS)\n return currentT\n}\n\nfunction newtonRaphsonIterate(aX, aGuessT, mX1, mX2) {\n for (let i = 0; i < NEWTON_ITERATIONS; ++i) {\n const currentSlope = getSlope(aGuessT, mX1, mX2)\n if (currentSlope === 0.0) {\n return aGuessT\n }\n const currentX = calcBezier(aGuessT, mX1, mX2) - aX\n aGuessT -= currentX / currentSlope\n }\n return aGuessT\n}\n\nfunction LinearEasing(x) {\n return x\n}\n\nexport default function bezier(mX1, mY1, mX2, mY2) {\n if (!(0 <= mX1 && mX1 <= 1 && 0 <= mX2 && mX2 <= 1)) {\n throw new Error('bezier x values must be in [0, 1] range')\n }\n\n if (mX1 === mY1 && mX2 === mY2) {\n return LinearEasing\n }\n\n // Precompute samples table\n const sampleValues = float32ArraySupported ? new Float32Array(kSplineTableSize) : new Array(kSplineTableSize)\n for (let i = 0; i < kSplineTableSize; ++i) {\n sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2)\n }\n\n function getTForX(aX) {\n let intervalStart = 0.0\n let currentSample = 1\n const lastSample = kSplineTableSize - 1\n\n for (; currentSample !== lastSample && sampleValues[currentSample] <= aX; ++currentSample) {\n intervalStart += kSampleStepSize\n }\n --currentSample\n\n // Interpolate to provide an initial guess for t\n const dist = (aX - sampleValues[currentSample]) / (sampleValues[currentSample + 1] - sampleValues[currentSample])\n const guessForT = intervalStart + dist * kSampleStepSize\n\n const initialSlope = getSlope(guessForT, mX1, mX2)\n if (initialSlope >= NEWTON_MIN_SLOPE) {\n return newtonRaphsonIterate(aX, guessForT, mX1, mX2)\n } else if (initialSlope === 0.0) {\n return guessForT\n } else {\n return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2)\n }\n }\n\n return function BezierEasing(x) {\n // Because JavaScript number are imprecise, we should guarantee the extremes are right.\n if (x === 0 || x === 1) {\n return x\n }\n return calcBezier(getTForX(x), mY1, mY2)\n }\n}\n","import BezierEasing from './bezier'\n\nconst lerp = (x: number, y: number, a: number) => x * (1 - a) + y * a\nconst clamp = (a: number, min = 0, max = 1) => Math.min(max, Math.max(min, a))\nconst invlerp = (x: number, y: number, a: number) => clamp((a - x) / (y - x))\n\nconst roundPixel = (num: number): number => Math.round(num * 10) / 10\nconst roundTransparency = (num: number): number => Math.round(num * 1000) / 1000\n\ntype GetSmoothShadowOptions = {\n distance?: number // 1-1000\n intensity?: number // 0-1\n sharpness?: number // 0-1\n color?: [number, number, number] // [0-255, 0-255, 0-255]\n lightPosition?: [number, number] // [-1 - 1, -1 - 1], where 0 is the center\n}\n\nexport const getSmoothShadow = (options: GetSmoothShadowOptions): string => {\n // establish good defaults\n const defaults = {\n distance: 100,\n intensity: 0.4,\n sharpness: 0.7,\n color: [0, 0, 0],\n lightPosition: [-0.35, -0.5]\n }\n // can't do { ...defaults, ...options } because if a value in options is set to undefined, it will override a default value\n const distance = options?.distance || defaults.distance\n const intensity = options?.intensity || defaults.intensity\n const sharpness = options?.sharpness || defaults.sharpness\n const color = options?.color || defaults.color\n const lightPosition = options?.lightPosition || defaults.lightPosition\n // in terms of performance it makes sense to limit a maximum\n const maxDistance = 2000\n const maxLayers = 24\n // clamp user values so the result doesnt go apeshit\n const cdistance = clamp(distance * 2, 0, maxDistance)\n const cintensity = clamp(intensity, 0, 1)\n const csharpness = clamp(sharpness, 0, 1)\n // the fractional distance to the maximum 0-1\n const interpolatedDistance = invlerp(1, maxDistance, cdistance)\n // the more distance, the more layers\n const amountEasing = BezierEasing(0.25, 1 - interpolatedDistance, 0.5, 1)\n // dont forget to round since we can only handle an integer amount of layers\n const amountLayers = Math.round(maxLayers * amountEasing(interpolatedDistance))\n // no reason to make this dynamic as it always looks good\n const distanceTransparency = BezierEasing(0, 0.3, 0, 0.06)\n // we want short distances to have enough opacity to look good\n const transparencyBase = (distanceTransparency(interpolatedDistance) / interpolatedDistance) * 6.5\n // now factor in intensity\n const finalTransparency = (transparencyBase / maxLayers) * cintensity\n // no reason to make this dynamic as it always looks good\n const transparencyEasing = BezierEasing(0, 1, 0.8, 0.5)\n // take light position into consideration\n const distanceX = cdistance * (lightPosition[0] * -1)\n const distanceY = cdistance * (lightPosition[1] * -1)\n // maxBlur scales with distance\n const maxBlur = lerp(200, 500, interpolatedDistance)\n // factor in sharpness to base blur value\n const finalBlur = lerp(100, maxBlur, csharpness)\n // this one's a little tricky, but for good looks we want multiple layers of ease\n const blurSharpnessEase = BezierEasing(1, 0, 1, 0)\n const easingBlurSharpness = lerp(0, 2, blurSharpnessEase(1 - csharpness))\n const blurEasing = BezierEasing(1, easingBlurSharpness, 1, easingBlurSharpness)\n // closer distances need to be paired with sharpness slightly differently to look good\n const easingSharpness = lerp(0, 0.075, 1 - csharpness)\n const distanceEasing = BezierEasing(1, easingSharpness, 1, easingSharpness)\n // iterate of the all layers and generate the final box-shadow string\n return Array.from(Array(amountLayers))\n .map((_, i) => {\n const transparencyCoeff = transparencyEasing(i / amountLayers)\n const distanceCoeff = distanceEasing(i / amountLayers)\n const blurCoeff = blurEasing(i / amountLayers)\n const x = roundPixel(distanceX * distanceCoeff)\n const y = roundPixel(distanceY * distanceCoeff)\n const b = roundPixel(finalBlur * blurCoeff)\n const t = roundTransparency(finalTransparency * transparencyCoeff)\n return `${x}px ${y}px ${b}px rgba(${color[0]},${color[1]},${color[2]},${t})`\n })\n .join(', ')\n}\n"],"names":["NEWTON_ITERATIONS","NEWTON_MIN_SLOPE","SUBDIVISION_PRECISION","SUBDIVISION_MAX_ITERATIONS","kSplineTableSize","kSampleStepSize","float32ArraySupported","Float32Array","A","aA1","aA2","B","C","calcBezier","aT","getSlope","binarySubdivide","aX","aA","aB","mX1","mX2","let","currentX","currentT","i","Math","abs","newtonRaphsonIterate","aGuessT","currentSlope","LinearEasing","x","bezier","mY1","mY2","Error","sampleValues","Array","intervalStart","currentSample","lastSample","dist","initialSlope","guessForT","lerp","y","a","clamp","min","max","invlerp","roundPixel","round","num","roundTransparency","getSmoothShadow","defaults","distance","intensity","sharpness","color","lightPosition","options","cdistance","cintensity","csharpness","interpolatedDistance","amountEasing","BezierEasing","amountLayers","finalTransparency","transparencyEasing","distanceX","distanceY","maxBlur","finalBlur","blurSharpnessEase","easingBlurSharpness","blurEasing","easingSharpness","distanceEasing","from","map","_","transparencyCoeff","distanceCoeff","blurCoeff","b","t","join"],"mappings":"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"}
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "smooth-shadow",
3 | "version": "1.0.0",
4 | "description": "Opionated library to generate consistent smooth box-shadows, ideally for design token generation.",
5 | "main": "lib/index.js",
6 | "module": "lib/index.mjs",
7 | "typings": "lib/index.d.ts",
8 | "author": {
9 | "name": "Thomas Strobl",
10 | "email": "thomas@thomas-strobl.com",
11 | "url": "https://thomas-strobl.com"
12 | },
13 | "scripts": {
14 | "build-demo": "rollup -c rollup.demo.config.js",
15 | "build": "rollup -c",
16 | "test": "jest --config jest.json --coverage",
17 | "benchmark": "node benchmark/index.js"
18 | },
19 | "files": [
20 | "lib/index.js",
21 | "lib/index.d.ts",
22 | "lib/index.js.map",
23 | "lib/index.mjs",
24 | "lib/index.mjs.map"
25 | ],
26 | "repository": {
27 | "type": "git",
28 | "url": "git+https://github.com/tom2strobl/smooth-shadow.git"
29 | },
30 | "keywords": [
31 | "css",
32 | "box-shadow",
33 | "shadow",
34 | "smooth",
35 | "easing"
36 | ],
37 | "license": "MIT",
38 | "np": {
39 | "2fa": false
40 | },
41 | "bugs": {
42 | "url": "https://github.com/tom2strobl/smooth-shadow/issues"
43 | },
44 | "homepage": "https://github.com/tom2strobl/smooth-shadow#readme",
45 | "devDependencies": {
46 | "@babel/cli": "^7.17.6",
47 | "@babel/core": "^7.17.8",
48 | "@babel/preset-env": "^7.16.11",
49 | "@babel/preset-react": "^7.18.6",
50 | "@babel/preset-typescript": "^7.16.7",
51 | "@rollup/plugin-babel": "^6.0.0",
52 | "@rollup/plugin-commonjs": "^23.0.0",
53 | "@rollup/plugin-node-resolve": "^15.0.0",
54 | "@rollup/plugin-replace": "^5.0.0",
55 | "@rollup/plugin-typescript": "^8.5.0",
56 | "babel-jest": "^27.5.1",
57 | "benny": "^3.7.1",
58 | "esbuild": "^0.15.7",
59 | "jest": "^27.5.1",
60 | "prettier": "^2.6.2",
61 | "process": "^0.11.10",
62 | "react": "^18.2.0",
63 | "react-dom": "^18.2.0",
64 | "rollup": "^2.79.0",
65 | "rollup-plugin-dts": "^4.2.2",
66 | "rollup-plugin-esbuild": "^4.10.1",
67 | "rollup-plugin-uglify": "^6.0.4",
68 | "tslib": "^2.4.0",
69 | "typescript": "^4.6.3"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import dts from 'rollup-plugin-dts'
2 | import esbuild from 'rollup-plugin-esbuild'
3 | import { uglify } from 'rollup-plugin-uglify'
4 |
5 | const name = require('./package.json').main.replace(/\.js$/, '')
6 |
7 | const bundle = (config) => ({
8 | ...config,
9 | input: 'src/index.ts',
10 | external: (id) => !/^[./]/.test(id)
11 | })
12 |
13 | export default [
14 | bundle({
15 | plugins: [esbuild(), uglify()],
16 | output: [
17 | {
18 | file: `${name}.js`,
19 | format: 'cjs',
20 | sourcemap: true
21 | },
22 | {
23 | file: `${name}.mjs`,
24 | format: 'es',
25 | sourcemap: true
26 | }
27 | ]
28 | }),
29 | bundle({
30 | plugins: [dts()],
31 | output: {
32 | file: `${name}.d.ts`,
33 | format: 'es'
34 | }
35 | })
36 | ]
37 |
--------------------------------------------------------------------------------
/rollup.demo.config.js:
--------------------------------------------------------------------------------
1 | import { babel } from '@rollup/plugin-babel'
2 | import { nodeResolve } from '@rollup/plugin-node-resolve'
3 | import commonjs from '@rollup/plugin-commonjs'
4 | import replace from '@rollup/plugin-replace'
5 |
6 | const config = {
7 | input: 'docs/index.js',
8 | output: {
9 | file: `docs/index-bundle.js`,
10 | format: 'iife'
11 | },
12 | plugins: [
13 | nodeResolve({
14 | extensions: ['.js']
15 | }),
16 | replace({
17 | 'process.env.NODE_ENV': JSON.stringify('development')
18 | }),
19 | babel({ babelHelpers: 'bundled' }),
20 | commonjs()
21 | ]
22 | }
23 |
24 | export default config
25 |
--------------------------------------------------------------------------------
/src/bezier.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable yoda */
2 | /* eslint-disable no-plusplus */
3 | /**
4 | * https://github.com/gre/bezier-easing
5 | * BezierEasing - use bezier curve for transition easing function
6 | * by Gaëtan Renaudeau 2014 - 2015 – MIT License
7 | */
8 |
9 | // These values are established by empiricism with tests (tradeoff: performance VS precision)
10 | const NEWTON_ITERATIONS = 4
11 | const NEWTON_MIN_SLOPE = 0.001
12 | const SUBDIVISION_PRECISION = 0.0000001
13 | const SUBDIVISION_MAX_ITERATIONS = 10
14 |
15 | const kSplineTableSize = 11
16 | const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0)
17 |
18 | const float32ArraySupported = typeof Float32Array === 'function'
19 |
20 | function A(aA1, aA2) {
21 | return 1.0 - 3.0 * aA2 + 3.0 * aA1
22 | }
23 | function B(aA1, aA2) {
24 | return 3.0 * aA2 - 6.0 * aA1
25 | }
26 | function C(aA1) {
27 | return 3.0 * aA1
28 | }
29 |
30 | // Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2.
31 | function calcBezier(aT, aA1, aA2) {
32 | return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT
33 | }
34 |
35 | // Returns dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2.
36 | function getSlope(aT, aA1, aA2) {
37 | return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1)
38 | }
39 |
40 | function binarySubdivide(aX, aA, aB, mX1, mX2) {
41 | let currentX
42 | let currentT
43 | let i = 0
44 | do {
45 | currentT = aA + (aB - aA) / 2.0
46 | currentX = calcBezier(currentT, mX1, mX2) - aX
47 | if (currentX > 0.0) {
48 | aB = currentT
49 | } else {
50 | aA = currentT
51 | }
52 | } while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS)
53 | return currentT
54 | }
55 |
56 | function newtonRaphsonIterate(aX, aGuessT, mX1, mX2) {
57 | for (let i = 0; i < NEWTON_ITERATIONS; ++i) {
58 | const currentSlope = getSlope(aGuessT, mX1, mX2)
59 | if (currentSlope === 0.0) {
60 | return aGuessT
61 | }
62 | const currentX = calcBezier(aGuessT, mX1, mX2) - aX
63 | aGuessT -= currentX / currentSlope
64 | }
65 | return aGuessT
66 | }
67 |
68 | function LinearEasing(x) {
69 | return x
70 | }
71 |
72 | export default function bezier(mX1, mY1, mX2, mY2) {
73 | if (!(0 <= mX1 && mX1 <= 1 && 0 <= mX2 && mX2 <= 1)) {
74 | throw new Error('bezier x values must be in [0, 1] range')
75 | }
76 |
77 | if (mX1 === mY1 && mX2 === mY2) {
78 | return LinearEasing
79 | }
80 |
81 | // Precompute samples table
82 | const sampleValues = float32ArraySupported ? new Float32Array(kSplineTableSize) : new Array(kSplineTableSize)
83 | for (let i = 0; i < kSplineTableSize; ++i) {
84 | sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2)
85 | }
86 |
87 | function getTForX(aX) {
88 | let intervalStart = 0.0
89 | let currentSample = 1
90 | const lastSample = kSplineTableSize - 1
91 |
92 | for (; currentSample !== lastSample && sampleValues[currentSample] <= aX; ++currentSample) {
93 | intervalStart += kSampleStepSize
94 | }
95 | --currentSample
96 |
97 | // Interpolate to provide an initial guess for t
98 | const dist = (aX - sampleValues[currentSample]) / (sampleValues[currentSample + 1] - sampleValues[currentSample])
99 | const guessForT = intervalStart + dist * kSampleStepSize
100 |
101 | const initialSlope = getSlope(guessForT, mX1, mX2)
102 | if (initialSlope >= NEWTON_MIN_SLOPE) {
103 | return newtonRaphsonIterate(aX, guessForT, mX1, mX2)
104 | } else if (initialSlope === 0.0) {
105 | return guessForT
106 | } else {
107 | return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2)
108 | }
109 | }
110 |
111 | return function BezierEasing(x) {
112 | // Because JavaScript number are imprecise, we should guarantee the extremes are right.
113 | if (x === 0 || x === 1) {
114 | return x
115 | }
116 | return calcBezier(getTForX(x), mY1, mY2)
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/index.test.js:
--------------------------------------------------------------------------------
1 | import { getSmoothShadow } from './'
2 |
3 | test('usage without arguments returns string', () => {
4 | expect(typeof getSmoothShadow()).toEqual('string')
5 | })
6 | test('usage with 1 argument returns string', () => {
7 | expect(typeof getSmoothShadow({ distance: 100 })).toEqual('string')
8 | })
9 | test('usage with 2 arguments returns string', () => {
10 | expect(typeof getSmoothShadow({ distance: 100, intensity: 0.5 })).toEqual('string')
11 | })
12 | test('usage with 3 arguments returns string', () => {
13 | expect(typeof getSmoothShadow({ distance: 100, intensity: 0.5, sharpness: 0.5 })).toEqual('string')
14 | })
15 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import BezierEasing from './bezier'
2 |
3 | const lerp = (x: number, y: number, a: number) => x * (1 - a) + y * a
4 | const clamp = (a: number, min = 0, max = 1) => Math.min(max, Math.max(min, a))
5 | const invlerp = (x: number, y: number, a: number) => clamp((a - x) / (y - x))
6 |
7 | const roundPixel = (num: number): number => Math.round(num * 10) / 10
8 | const roundTransparency = (num: number): number => Math.round(num * 1000) / 1000
9 |
10 | type GetSmoothShadowOptions = {
11 | distance?: number // 1-1000
12 | intensity?: number // 0-1
13 | sharpness?: number // 0-1
14 | color?: [number, number, number] // [0-255, 0-255, 0-255]
15 | lightPosition?: [number, number] // [-1 - 1, -1 - 1], where 0 is the center
16 | }
17 |
18 | export const getSmoothShadow = (options: GetSmoothShadowOptions): string => {
19 | // establish good defaults
20 | const defaults = {
21 | distance: 100,
22 | intensity: 0.4,
23 | sharpness: 0.7,
24 | color: [0, 0, 0],
25 | lightPosition: [-0.35, -0.5]
26 | }
27 | // can't do { ...defaults, ...options } because if a value in options is set to undefined, it will override a default value
28 | const distance = options?.distance || defaults.distance
29 | const intensity = options?.intensity || defaults.intensity
30 | const sharpness = options?.sharpness || defaults.sharpness
31 | const color = options?.color || defaults.color
32 | const lightPosition = options?.lightPosition || defaults.lightPosition
33 | // in terms of performance it makes sense to limit a maximum
34 | const maxDistance = 2000
35 | const maxLayers = 24
36 | // clamp user values so the result doesnt go apeshit
37 | const cdistance = clamp(distance * 2, 0, maxDistance)
38 | const cintensity = clamp(intensity, 0, 1)
39 | const csharpness = clamp(sharpness, 0, 1)
40 | // the fractional distance to the maximum 0-1
41 | const interpolatedDistance = invlerp(1, maxDistance, cdistance)
42 | // the more distance, the more layers
43 | const amountEasing = BezierEasing(0.25, 1 - interpolatedDistance, 0.5, 1)
44 | // dont forget to round since we can only handle an integer amount of layers
45 | const amountLayers = Math.round(maxLayers * amountEasing(interpolatedDistance))
46 | // no reason to make this dynamic as it always looks good
47 | const distanceTransparency = BezierEasing(0, 0.3, 0, 0.06)
48 | // we want short distances to have enough opacity to look good
49 | const transparencyBase = (distanceTransparency(interpolatedDistance) / interpolatedDistance) * 6.5
50 | // now factor in intensity
51 | const finalTransparency = (transparencyBase / maxLayers) * cintensity
52 | // no reason to make this dynamic as it always looks good
53 | const transparencyEasing = BezierEasing(0, 1, 0.8, 0.5)
54 | // take light position into consideration
55 | const distanceX = cdistance * (lightPosition[0] * -1)
56 | const distanceY = cdistance * (lightPosition[1] * -1)
57 | // maxBlur scales with distance
58 | const maxBlur = lerp(200, 500, interpolatedDistance)
59 | // factor in sharpness to base blur value
60 | const finalBlur = lerp(100, maxBlur, csharpness)
61 | // this one's a little tricky, but for good looks we want multiple layers of ease
62 | const blurSharpnessEase = BezierEasing(1, 0, 1, 0)
63 | const easingBlurSharpness = lerp(0, 2, blurSharpnessEase(1 - csharpness))
64 | const blurEasing = BezierEasing(1, easingBlurSharpness, 1, easingBlurSharpness)
65 | // closer distances need to be paired with sharpness slightly differently to look good
66 | const easingSharpness = lerp(0, 0.075, 1 - csharpness)
67 | const distanceEasing = BezierEasing(1, easingSharpness, 1, easingSharpness)
68 | // iterate of the all layers and generate the final box-shadow string
69 | return Array.from(Array(amountLayers))
70 | .map((_, i) => {
71 | const transparencyCoeff = transparencyEasing(i / amountLayers)
72 | const distanceCoeff = distanceEasing(i / amountLayers)
73 | const blurCoeff = blurEasing(i / amountLayers)
74 | const x = roundPixel(distanceX * distanceCoeff)
75 | const y = roundPixel(distanceY * distanceCoeff)
76 | const b = roundPixel(finalBlur * blurCoeff)
77 | const t = roundTransparency(finalTransparency * transparencyCoeff)
78 | return `${x}px ${y}px ${b}px rgba(${color[0]},${color[1]},${color[2]},${t})`
79 | })
80 | .join(', ')
81 | }
82 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------