├── .npmignore ├── docs ├── bubbly.gif ├── img │ ├── config0.png │ ├── config1.png │ ├── config2.png │ ├── config3.png │ ├── config4.png │ └── config5.png ├── bubbly-bg.js ├── main.js ├── styles.css └── index.html ├── minify.js ├── package.json ├── .gitignore ├── dist └── bubbly-bg.js ├── index.d.ts ├── src └── bubbly-bg.js ├── README.md └── LICENSE /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .npmignore 3 | gulpfile.js 4 | docs 5 | -------------------------------------------------------------------------------- /docs/bubbly.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tipsy/bubbly-bg/HEAD/docs/bubbly.gif -------------------------------------------------------------------------------- /docs/img/config0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tipsy/bubbly-bg/HEAD/docs/img/config0.png -------------------------------------------------------------------------------- /docs/img/config1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tipsy/bubbly-bg/HEAD/docs/img/config1.png -------------------------------------------------------------------------------- /docs/img/config2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tipsy/bubbly-bg/HEAD/docs/img/config2.png -------------------------------------------------------------------------------- /docs/img/config3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tipsy/bubbly-bg/HEAD/docs/img/config3.png -------------------------------------------------------------------------------- /docs/img/config4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tipsy/bubbly-bg/HEAD/docs/img/config4.png -------------------------------------------------------------------------------- /docs/img/config5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tipsy/bubbly-bg/HEAD/docs/img/config5.png -------------------------------------------------------------------------------- /minify.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const Terser = require("terser"); 3 | 4 | (async () => { 5 | let minified = (await Terser.minify(fs.readFileSync("src/bubbly-bg.js", "utf8"))).code; 6 | fs.writeFileSync("dist/bubbly-bg.js", minified, "utf8"); 7 | fs.writeFileSync("docs/bubbly-bg.js", minified, "utf8"); 8 | })(); 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bubbly-bg", 3 | "version": "1.0.0", 4 | "description": "Lightweight and beautiful bubbly backgrounds in less than 1kB", 5 | "main": "dist/bubbly-bg.js", 6 | "types": "index.d.ts", 7 | "files": [ 8 | "dist/bubbly-bg.js", 9 | "src/bubbly-bg.js", 10 | "index.d.ts" 11 | ], 12 | "keywords": [ 13 | "background", 14 | "canvas", 15 | "animation", 16 | "bubbles", 17 | "bubbly", 18 | "particles", 19 | "lightweight" 20 | ], 21 | "scripts": { 22 | "build": "node minify.js", 23 | "watch": "nodemon --watch src --exec 'npm run build'" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/tipsy/bubbly-bg.git" 28 | }, 29 | "author": "David Åse", 30 | "license": "Apache-2.0", 31 | "bugs": { 32 | "url": "https://github.com/tipsy/bubbly-bg/issues" 33 | }, 34 | "homepage": "https://github.com/tipsy/bubbly-bg#readme", 35 | "devDependencies": { 36 | "nodemon": "^3.1.10", 37 | "terser": "^5.44.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .cache 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | -------------------------------------------------------------------------------- /dist/bubbly-bg.js: -------------------------------------------------------------------------------- 1 | window.bubbly=function(t={}){const e=t.canvas??(()=>{let t=document.createElement("canvas");return t.setAttribute("style","position:fixed;z-index:-1;left:0;top:0;min-width:100vw;min-height:100vh;"),t.width=window.innerWidth,t.height=window.innerHeight,document.body.appendChild(t),t})(),o=e.getContext("2d"),{compose:a,bubbles:i,background:r,animate:n}={compose:t.compose??"lighter",bubbles:Object.assign({count:Math.floor(.02*(e.width+e.height)),radius:()=>4+Math.random()*window.innerWidth/25,fill:()=>`hsla(0, 0%, 100%, ${.1*Math.random()})`,angle:()=>Math.random()*Math.PI*2,velocity:()=>.1+.5*Math.random(),shadow:()=>null,stroke:()=>null},t.bubbles??{}),background:t.background??(()=>"#2AE"),animate:!1!==t.animate};i.objectCreator=t.bubbles?.objectCreator??(()=>({r:i.radius(),f:i.fill(),x:Math.random()*e.width,y:Math.random()*e.height,a:i.angle(),v:i.velocity(),sh:i.shadow(),st:i.stroke(),draw:(t,e)=>{e.sh&&(t.shadowColor=e.sh.color,t.shadowBlur=e.sh.blur),t.fillStyle=e.f,t.beginPath(),t.arc(e.x,e.y,e.r,0,2*Math.PI),t.fill(),e.st&&(t.strokeStyle=e.st.color,t.lineWidth=e.st.width,t.stroke())}}));let h=Array.from({length:i.count},i.objectCreator);requestAnimationFrame(function t(){if(null===e.parentNode)return h=[],cancelAnimationFrame(t);n&&requestAnimationFrame(t);o.globalCompositeOperation="source-over",o.fillStyle=r(o),o.fillRect(0,0,e.width,e.height),o.globalCompositeOperation=a;for(const t of h)t.draw(o,t),t.x+=Math.cos(t.a)*t.v,t.y+=Math.sin(t.a)*t.v,t.x-t.r>e.width&&(t.x=-t.r),t.x+t.r<0&&(t.x=e.width+t.r),t.y-t.r>e.height&&(t.y=-t.r),t.y+t.r<0&&(t.y=e.height+t.r)})}; -------------------------------------------------------------------------------- /docs/bubbly-bg.js: -------------------------------------------------------------------------------- 1 | window.bubbly=function(t={}){const e=t.canvas??(()=>{let t=document.createElement("canvas");return t.setAttribute("style","position:fixed;z-index:-1;left:0;top:0;min-width:100vw;min-height:100vh;"),t.width=window.innerWidth,t.height=window.innerHeight,document.body.appendChild(t),t})(),o=e.getContext("2d"),{compose:a,bubbles:i,background:r,animate:n}={compose:t.compose??"lighter",bubbles:Object.assign({count:Math.floor(.02*(e.width+e.height)),radius:()=>4+Math.random()*window.innerWidth/25,fill:()=>`hsla(0, 0%, 100%, ${.1*Math.random()})`,angle:()=>Math.random()*Math.PI*2,velocity:()=>.1+.5*Math.random(),shadow:()=>null,stroke:()=>null},t.bubbles??{}),background:t.background??(()=>"#2AE"),animate:!1!==t.animate};i.objectCreator=t.bubbles?.objectCreator??(()=>({r:i.radius(),f:i.fill(),x:Math.random()*e.width,y:Math.random()*e.height,a:i.angle(),v:i.velocity(),sh:i.shadow(),st:i.stroke(),draw:(t,e)=>{e.sh&&(t.shadowColor=e.sh.color,t.shadowBlur=e.sh.blur),t.fillStyle=e.f,t.beginPath(),t.arc(e.x,e.y,e.r,0,2*Math.PI),t.fill(),e.st&&(t.strokeStyle=e.st.color,t.lineWidth=e.st.width,t.stroke())}}));let h=Array.from({length:i.count},i.objectCreator);requestAnimationFrame(function t(){if(null===e.parentNode)return h=[],cancelAnimationFrame(t);n&&requestAnimationFrame(t);o.globalCompositeOperation="source-over",o.fillStyle=r(o),o.fillRect(0,0,e.width,e.height),o.globalCompositeOperation=a;for(const t of h)t.draw(o,t),t.x+=Math.cos(t.a)*t.v,t.y+=Math.sin(t.a)*t.v,t.x-t.r>e.width&&(t.x=-t.r),t.x+t.r<0&&(t.x=e.width+t.r),t.y-t.r>e.height&&(t.y=-t.r),t.y+t.r<0&&(t.y=e.height+t.r)})}; -------------------------------------------------------------------------------- /docs/main.js: -------------------------------------------------------------------------------- 1 | function configToText(config) { 2 | return ` 3 | bubbly({ 4 | canvas: ${config.cv}, 5 | compose: ${config.compose ? `"${config.compose}"` : undefined}, 6 | bubbles: { 7 | count: ${config.bubbles?.count}, 8 | radius: ${config.bubbles?.radius}, 9 | fill: ${config.bubbles?.fill}, 10 | angle: ${config.bubbles?.angle}, 11 | velocity: ${config.bubbles?.velocity}, 12 | shadow: ${config.bubbles?.shadow}, 13 | stroke: ${config.bubbles?.stroke}, 14 | objectCreator: ${formatFunction(config.bubbles?.objectCreator, 12)}, 15 | }, 16 | background: ${formatFunction(config.background, 8)}, 17 | animate: ${config.animate}, 18 | });`.trim() 19 | .split('\n') 20 | .filter(line => !line.includes(': undefined,')) // remove undefined values 21 | .join('\n'); 22 | } 23 | 24 | function formatFunction(func, targetIndent) { 25 | if (!func) return undefined 26 | if (func.toString().split("\n").length === 1) return func.toString(); 27 | const lines = func.toString().split("\n"); 28 | const indentOfFirstLine = lines[1].match(/^\s*/)[0].length; 29 | if (indentOfFirstLine === targetIndent) { 30 | return func.toString 31 | } else if (indentOfFirstLine < targetIndent) { 32 | const indent = targetIndent - indentOfFirstLine; 33 | return lines.map((line, i) => i === 0 ? line : " ".repeat(indent) + line).join('\n'); 34 | } else if (indentOfFirstLine > targetIndent) { 35 | const indent = indentOfFirstLine - targetIndent; 36 | return lines.map(line => line.replace(" ".repeat(indent), "")).join('\n'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docs/styles.css: -------------------------------------------------------------------------------- 1 | [v-cloak] { 2 | display: none; 3 | } 4 | 5 | * { 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | min-height: 100vh; 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | } 15 | 16 | .overlay { 17 | background: rgba(255, 255, 255, 0.1); 18 | border: 1px solid rgba(255, 255, 255, 0.4); 19 | padding: 24px; 20 | text-align: center; 21 | border-radius: 4px; 22 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); 23 | backdrop-filter: blur(8px); 24 | max-width: 90vw; 25 | } 26 | 27 | .text, .text a { 28 | font: 16px monospace; 29 | font-weight: bold; 30 | color: black; 31 | text-shadow: 1px 1px 50px rgba(255, 255, 255, 1); 32 | } 33 | 34 | .text + .text { 35 | margin-top: 6px; 36 | } 37 | 38 | @media (min-width: 1100px) { 39 | .overlay { 40 | max-width: 800px; 41 | } 42 | } 43 | 44 | .examples { 45 | display: flex; 46 | flex-wrap: wrap; 47 | justify-content: space-between; 48 | } 49 | 50 | .example { 51 | width: calc(33% - 12px); 52 | margin-bottom: 24px; 53 | } 54 | 55 | @media (max-width: 480px) { 56 | .example { 57 | width: calc(50% - 12px); 58 | } 59 | } 60 | 61 | .example img { 62 | cursor: pointer; 63 | display: block; 64 | width: 100%; 65 | border-radius: 4px; 66 | } 67 | 68 | .example img:hover { 69 | filter: saturate(1.1) contrast(1.1); 70 | } 71 | 72 | .example { 73 | position: relative; 74 | } 75 | 76 | .example .copy { 77 | position: absolute; 78 | right: 0; 79 | top: 0; 80 | font: 14px monospace; 81 | color: white; 82 | background-color: rgba(0, 0, 0, 0.2); 83 | padding: 6px 10px; 84 | z-index: 1001; 85 | cursor: pointer; 86 | border: none; 87 | border-top-right-radius: 4px; 88 | border-bottom-left-radius: 4px; 89 | } 90 | 91 | .example .config:hover { 92 | background-color: rgba(0, 0, 0, 0.5); 93 | } 94 | 95 | .toast { 96 | background: rgba(0, 0, 0, 0.5) !important; 97 | } 98 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration options for bubbly-bg 3 | */ 4 | export interface BubblyConfig { 5 | /** 6 | * Canvas element to use. If not provided, a new canvas will be created and appended to the body. 7 | */ 8 | canvas?: HTMLCanvasElement; 9 | 10 | /** 11 | * Global composite operation for drawing bubbles. 12 | * @default "lighter" 13 | */ 14 | compose?: GlobalCompositeOperation; 15 | 16 | /** 17 | * Whether to animate the bubbles. 18 | * @default true 19 | */ 20 | animate?: boolean; 21 | 22 | /** 23 | * Background color or gradient function. 24 | * @default (ctx) => "#2AE" 25 | */ 26 | background?: string | ((ctx: CanvasRenderingContext2D) => string | CanvasGradient); 27 | 28 | /** 29 | * Bubble configuration options. 30 | */ 31 | bubbles?: { 32 | /** 33 | * Number of bubbles to create. 34 | * @default Math.floor((canvas.width + canvas.height) * 0.02) 35 | */ 36 | count?: number; 37 | 38 | /** 39 | * Function that returns the radius of a bubble. 40 | * @default () => 4 + Math.random() * window.innerWidth / 25 41 | */ 42 | radius?: () => number; 43 | 44 | /** 45 | * Function that returns the fill color of a bubble. 46 | * @default () => `hsla(0, 0%, 100%, ${Math.random() * 0.1})` 47 | */ 48 | fill?: () => string; 49 | 50 | /** 51 | * Function that returns the angle of a bubble's movement. 52 | * @default () => Math.random() * Math.PI * 2 53 | */ 54 | angle?: () => number; 55 | 56 | /** 57 | * Function that returns the velocity of a bubble. 58 | * @default () => 0.1 + Math.random() * 0.5 59 | */ 60 | velocity?: () => number; 61 | 62 | /** 63 | * Function that returns shadow configuration for a bubble. 64 | * @default () => null 65 | */ 66 | shadow?: () => { blur: number; color: string } | null; 67 | 68 | /** 69 | * Function that returns stroke configuration for a bubble. 70 | * @default () => null 71 | */ 72 | stroke?: () => { width: number; color: string } | null; 73 | 74 | /** 75 | * Advanced: Custom bubble object creator function. 76 | * Allows complete control over bubble creation and rendering. 77 | */ 78 | objectCreator?: () => BubbleObject; 79 | }; 80 | } 81 | 82 | /** 83 | * Bubble object interface for custom bubble creators 84 | */ 85 | export interface BubbleObject { 86 | /** Radius or size of the bubble */ 87 | r: number; 88 | /** Fill color */ 89 | f?: string; 90 | /** X position */ 91 | x: number; 92 | /** Y position */ 93 | y: number; 94 | /** Angle of movement */ 95 | a: number; 96 | /** Velocity */ 97 | v: number; 98 | /** Shadow configuration */ 99 | sh?: { blur: number; color: string } | null; 100 | /** Stroke configuration */ 101 | st?: { width: number; color: string } | null; 102 | /** Custom draw function */ 103 | draw: (ctx: CanvasRenderingContext2D, bubble: BubbleObject) => void; 104 | /** Any additional custom properties */ 105 | [key: string]: any; 106 | } 107 | 108 | /** 109 | * Creates a bubbly background animation 110 | * @param config - Configuration options 111 | */ 112 | export function bubbly(config?: BubblyConfig): void; 113 | 114 | declare global { 115 | interface Window { 116 | bubbly: typeof bubbly; 117 | } 118 | } 119 | 120 | -------------------------------------------------------------------------------- /src/bubbly-bg.js: -------------------------------------------------------------------------------- 1 | window.bubbly = function (userConfig = {}) { 2 | // we need to create a canvas element if the user didn't provide one 3 | const cv = userConfig.canvas ?? (() => { 4 | let canvas = document.createElement("canvas"); 5 | canvas.setAttribute("style", "position:fixed;z-index:-1;left:0;top:0;min-width:100vw;min-height:100vh;"); 6 | canvas.width = window.innerWidth; 7 | canvas.height = window.innerHeight; 8 | document.body.appendChild(canvas); 9 | return canvas; 10 | })(); 11 | const ctx = cv.getContext("2d"); 12 | // we destructure the config object (with default values as fallback) 13 | const {compose, bubbles, background, animate} = { 14 | compose: userConfig.compose ?? "lighter", 15 | bubbles: Object.assign({ // default values 16 | count: Math.floor((cv.width + cv.height) * 0.02), 17 | radius: () => 4 + Math.random() * window.innerWidth / 25, 18 | fill: () => `hsla(0, 0%, 100%, ${Math.random() * 0.1})`, 19 | angle: () => Math.random() * Math.PI * 2, 20 | velocity: () => 0.1 + Math.random() * 0.5, 21 | shadow: () => null, // ({blur: 4, color: "#fff"}) 22 | stroke: () => null, // ({width: 2, color: "#fff"}) 23 | }, userConfig.bubbles ?? {}), 24 | background: userConfig.background ?? (() => "#2AE"), 25 | animate: userConfig.animate !== false, 26 | } 27 | // this function contains a lot of references to its parent scope, 28 | // so it must be defined after the config is created 29 | bubbles.objectCreator = userConfig.bubbles?.objectCreator ?? (() => ({ 30 | r: bubbles.radius(), 31 | f: bubbles.fill(), 32 | x: Math.random() * cv.width, 33 | y: Math.random() * cv.height, 34 | a: bubbles.angle(), 35 | v: bubbles.velocity(), 36 | sh: bubbles.shadow(), 37 | st: bubbles.stroke(), 38 | draw: (ctx, bubble) => { 39 | if (bubble.sh) { 40 | ctx.shadowColor = bubble.sh.color; 41 | ctx.shadowBlur = bubble.sh.blur; 42 | } 43 | ctx.fillStyle = bubble.f; 44 | ctx.beginPath(); 45 | ctx.arc(bubble.x, bubble.y, bubble.r, 0, Math.PI * 2); 46 | ctx.fill(); 47 | if (bubble.st) { 48 | ctx.strokeStyle = bubble.st.color; 49 | ctx.lineWidth = bubble.st.width; 50 | ctx.stroke(); 51 | } 52 | } 53 | })); 54 | let bubbleArray = Array.from({length: bubbles.count}, bubbles.objectCreator); 55 | requestAnimationFrame(draw); 56 | function draw() { 57 | if (cv.parentNode === null) { 58 | bubbleArray = []; 59 | return cancelAnimationFrame(draw); 60 | } 61 | if (animate) { 62 | requestAnimationFrame(draw); 63 | } 64 | ctx.globalCompositeOperation = "source-over"; 65 | ctx.fillStyle = background(ctx); 66 | ctx.fillRect(0, 0, cv.width, cv.height); 67 | ctx.globalCompositeOperation = compose; 68 | for (const bubble of bubbleArray) { 69 | bubble.draw(ctx, bubble); 70 | bubble.x += Math.cos(bubble.a) * bubble.v; 71 | bubble.y += Math.sin(bubble.a) * bubble.v; 72 | if (bubble.x - bubble.r > cv.width) { 73 | bubble.x = -bubble.r; 74 | } 75 | if (bubble.x + bubble.r < 0) { 76 | bubble.x = cv.width + bubble.r; 77 | } 78 | if (bubble.y - bubble.r > cv.height) { 79 | bubble.y = -bubble.r; 80 | } 81 | if (bubble.y + bubble.r < 0) { 82 | bubble.y = cv.height + bubble.r; 83 | } 84 | } 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |