├── .editorconfig ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── postcss.config.js └── src ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── fonts ├── source-code-pro.woff2 └── work-sans.woff2 ├── images ├── share-url-logo.svg └── share-url-social.png ├── index.html ├── scripts ├── main.js ├── share-url-wc.js └── share-url.js └── styles ├── main.css ├── share-url.css ├── share-url └── share-url.css └── site ├── base.css ├── components.css ├── demo.css ├── layout.css ├── main.css ├── reset.css └── variables.css /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # Matches multiple files with brace expansion notation 14 | # Set default charset 15 | [*.{js,html}] 16 | charset = utf-8 17 | 18 | # 2 space indentation 19 | [*.{html,css,scss,js}] 20 | indent_style = space 21 | indent_size = 2 22 | 23 | # Markdown files 24 | [*.md] 25 | trim_trailing_whitespace = false 26 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | 35 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built. 36 | run: | 37 | npm ci 38 | npm run build 39 | 40 | - name: Setup Pages 41 | uses: actions/configure-pages@v4 42 | - name: Upload artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | # Upload entire repository 46 | path: 'src' 47 | - name: Deploy to GitHub Pages 48 | id: deployment 49 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .DS_Store 4 | Thumbs.db 5 | 6 | *.map 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Nigel O Toole 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Share URL 2 | ### Share a URL with Web Share, copy to clipboard or to a social platform 3 | 4 | Native Web Share API is used if available, can copy link to clipboard and share directly to social media. 5 | 6 | ### [Demo and documentation](http://nigelotoole.github.io/share-url/) 7 | 8 | --- 9 | ## Quick start 10 | ```javascript 11 | $ npm install @nigelotoole/share-url --save-dev 12 | ``` 13 | 14 | Import the JS into your project, add the elements to your HTML and initialize the plugin if needed. 15 | 16 | --- 17 | ### License 18 | MIT © Nigel O Toole 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nigelotoole/share-url", 3 | "homepage": "https://nigelotoole.github.io/share-url/", 4 | "author": "Nigel O Toole (http://www.purestructure.com)", 5 | "description": "Share a URL with Web Share, copy to clipboard or to social media", 6 | "keywords": [ 7 | "share", 8 | "webshare", 9 | "javascript", 10 | "social" 11 | ], 12 | "version": "1.3.0", 13 | "main": "src/scripts/share-url.js", 14 | "license": "MIT", 15 | "engines": { 16 | "node": ">=4" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/NigelOToole/share-url.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/NigelOToole/share-url/issues" 24 | }, 25 | "browserslist": [ 26 | "defaults" 27 | ], 28 | "devDependencies": { 29 | "@11ty/eleventy-dev-server": "^1.0.4", 30 | "concurrently": "^8.2.1", 31 | "cross-env": "^7.0.3", 32 | "postcss": "^8.4.31", 33 | "postcss-cli": "^10.1.0", 34 | "postcss-custom-media": "^10.0.2", 35 | "postcss-import": "^15.1.0", 36 | "postcss-preset-env": "^9.1.4", 37 | "rimraf": "^5.0.5" 38 | }, 39 | "scripts": { 40 | "clean": "rimraf src/**/*.map", 41 | "dev": "cross-env NODE_ENV=development && concurrently \"npm:dev:*\"", 42 | "dev:server": "npx @11ty/eleventy-dev-server --dir=src", 43 | "dev:styles": "postcss src/styles/site/main.css src/styles/share-url/share-url.css --dir src/styles --watch", 44 | "build": "npm run clean && cross-env NODE_ENV=production concurrently \"npm:build:*\"", 45 | "build:styles": "postcss src/styles/site/main.css src/styles/share-url/share-url.css --dir src/styles", 46 | "publish": "npm run build && npm publish --access public" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const isProduction = process.env.NODE_ENV === "production"; 2 | const plugins = [ 3 | require('postcss-import'), 4 | require('postcss-custom-media'), 5 | require('postcss-preset-env')({ 6 | stage: 1 7 | }), 8 | ]; 9 | 10 | module.exports = { 11 | map: isProduction ? false : { annotation: true, inline: false }, 12 | plugins 13 | } 14 | -------------------------------------------------------------------------------- /src/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NigelOToole/share-url/20a79ad1e8d1b6d54914cd40f4c6c6a0a267ea42/src/apple-touch-icon.png -------------------------------------------------------------------------------- /src/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NigelOToole/share-url/20a79ad1e8d1b6d54914cd40f4c6c6a0a267ea42/src/favicon-16x16.png -------------------------------------------------------------------------------- /src/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NigelOToole/share-url/20a79ad1e8d1b6d54914cd40f4c6c6a0a267ea42/src/favicon-32x32.png -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NigelOToole/share-url/20a79ad1e8d1b6d54914cd40f4c6c6a0a267ea42/src/favicon.ico -------------------------------------------------------------------------------- /src/fonts/source-code-pro.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NigelOToole/share-url/20a79ad1e8d1b6d54914cd40f4c6c6a0a267ea42/src/fonts/source-code-pro.woff2 -------------------------------------------------------------------------------- /src/fonts/work-sans.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NigelOToole/share-url/20a79ad1e8d1b6d54914cd40f4c6c6a0a267ea42/src/fonts/work-sans.woff2 -------------------------------------------------------------------------------- /src/images/share-url-logo.svg: -------------------------------------------------------------------------------- 1 | share-url-logo -------------------------------------------------------------------------------- /src/images/share-url-social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NigelOToole/share-url/20a79ad1e8d1b6d54914cd40f4c6c6a0a267ea42/src/images/share-url-social.png -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Share URL - Share a URL with Web Share, copy to clipboard or to social media 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 | 32 |

Share URL

33 | 34 | 38 |
39 |
40 | 41 |
42 | 43 |
44 |
45 | 46 |
47 |

Share a URL with Web Share, copy to clipboard or to social media

48 | 49 | Share URL logo 50 |
51 | 52 |

Features

53 |
    54 |
  • Native Web Share API is used if available
  • 55 |
  • Copy to clipboard
  • 56 |
  • Share directly to social platforms
  • 57 |
  • Updates text on share success
  • 58 |
59 | 60 |
61 |
62 | 63 | 64 |
65 |
66 | 67 |

Demos

68 | 69 |

Your browser does not support the Web Share API. Check your browser support at Can I Use. The below examples will only show the copy to clipboard button.

70 | 71 | 72 |

Share & Copy

73 |
74 | 75 | 76 |
77 | 78 | 79 |

Social media

80 |
81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 |
89 | 90 | 91 |

Custom links

92 |
93 | 94 |
95 | 96 | 97 |

Icons

98 |
99 | 100 | 105 | 106 | 111 | 112 |
113 | 114 | 115 |

Fallback element

116 |

The fallback element displays if JS or the share action is unavailable. You must wrap your text in an element for the styling to work properly.

117 | 118 |
119 | 123 | 124 | 128 |
129 | 130 | 131 |

Web Component

132 |
133 | 134 | 139 | 140 | 141 | 142 | 147 | 148 |
149 | 150 |
151 |
152 | 153 | 154 |
155 |
156 | 157 |

Installation

158 | 159 |
$ npm install @nigelotoole/share-url --save-dev
160 | 161 | 162 |

Usage

163 | 164 |

Import or add the JS into your project, add the elements to your HTML and initialize the plugin if needed.

165 | 166 | 167 |

JavaScript

168 | 169 |
import { ShareUrl, ShareUrlAuto } from 'share-url.js';
170 | 171 |

OR

172 | 173 |
<script src="share-url.js" type="module"></script>
174 | 175 | 176 |

Web Component

177 |
import { ShareUrl } from 'share-url-wc.js';
178 | 179 |

OR

180 | 181 |
<script src="share-url-wc.js" type="module"></script>
182 | 183 | 184 |

Initialize JS

185 | 186 |
// Initialize a single element
187 | const ShareUrlDefault = ShareUrl({ 
188 |   selector: '#share-url--default'
189 | });
190 | 
191 | // Initialize all elements with default options
192 | // These can be overridden by reinitializing or from data attributes on the element.
193 | ShareUrlAuto();
194 | 
195 | 196 | 197 |

Markup

198 | 199 |

JS

200 |
<button class="share-url">Share link</button>
201 | 202 |

Web Component

203 | 204 |
<share-url><button>Share link</button></share-url>
205 | 206 | 207 |

Options

208 | 209 |

The options can be set via initialization in the JS or by attributes on the element, which will be given the highest priority. The property is changed from camel case to dash case with with the prefix 'share' added for the JS version e.g. "activeClass" to "share-active-class".

210 | 211 |
<button class="share-url" data-share-active-class="is-open">
212 | 213 |
<share-url active-class="is-open">
214 | 215 |
216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 |
PropertyDefaultTypeDescription
selector'.share-url'String || ElementContainer element selector.
activeClass'is-active'StringCSS class when share has been successful.
action'share'StringAction keyword (share, clipboard, bluesky, facebook, linkedin, mastodon, reddit, threads, twitter) or a url e.g. "https://www.social.com/share".
urldocument.location.hrefStringURL to share.
titledocument.titleStringTitle text to share.
urlParameter'url'StringParameter for the url e.g. social.com?url=site.com
titleParameter'text'StringParameter for the title e.g. social.com?text=Check+out+site.com
textSelectornullStringElement to update the text, the element itself is the default.
textLabel''StingInitial text on the element, existing text is the default.
textSuccess'Shared'StringText shown when a share is successful.
maintainSizefalseBooleanElement will maintain its size after text is changed to prevent layout jank.
290 |
291 | 292 | 293 |

Compatibility

294 |

Supports all modern browsers at the time of release.

295 | 296 | 297 |

Demo site

298 |

Clone the repo from Github and run the commands below.

299 | 300 |
$ npm install
301 | $ npm run dev
302 | 
303 | 304 |

References

305 |

How to let the user share the website they are on by Thomas Steiner, webcare-webshare Web Component by Zach Leatherman, Adding a “share to mastodon” link to any web site – and here by Christian Heilmann and Adding a Share On Mastodon button to a website by Ben Tasker.

306 |

HTML web components by Jeremy Keith, HTML web components by Jim Nielsen, The different ways to instantiate a Web Component and Progressively enhancing a Web Component by Chris Ferdinandi.

307 | 308 |
309 |
310 | 311 |
312 | 313 | 314 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | -------------------------------------------------------------------------------- /src/scripts/main.js: -------------------------------------------------------------------------------- 1 | import { ShareUrl } from './share-url.js'; 2 | import { ShareUrl as ShareUrlWC } from './share-url-wc.js'; 3 | 4 | window.addEventListener('DOMContentLoaded', (event) => { 5 | 6 | // Demos 7 | const elements = document.querySelectorAll('.demos .share-url'); 8 | for (const item of elements) { 9 | ShareUrl({ selector: item }); 10 | }; 11 | 12 | // Browser support 13 | if (!navigator.share) { 14 | document.querySelector('.unsupported').classList.add('is-active'); 15 | } 16 | 17 | // Dynamically add the element - https://gomakethings.com/the-different-ways-to-instantiate-a-web-component/ 18 | // let dynamicElement = document.createElement('share-url'); 19 | // dynamicElement.setAttribute('action', 'clipboard'); 20 | // dynamicElement.innerHTML = ``; 21 | // document.querySelector('.demo--wc .group').append(dynamicElement); 22 | 23 | 24 | 25 | // Site 26 | 27 | // Encoded text 28 | const encodeElements = document.querySelectorAll('.encode'); 29 | for (const item of encodeElements) { 30 | let decode = atob(item.dataset['encode']); 31 | 32 | if (item.dataset['encodeAttribute']) { 33 | item.setAttribute(`${item.dataset['encodeAttribute']}`, `${decode}`); 34 | } 35 | } 36 | 37 | // Observe header height 38 | const observeHeader = function() { 39 | let element = document.querySelector('.header'); 40 | let height = 0; 41 | 42 | const resizeObserver = new ResizeObserver((entries) => { 43 | for (const entry of entries) { 44 | let heightNew = entry.contentBoxSize[0].blockSize; 45 | 46 | if (height !== heightNew) { 47 | height = entry.contentBoxSize[0].blockSize; 48 | 49 | document.documentElement.style.setProperty(`--header-height`, `${height}px`); 50 | } 51 | } 52 | 53 | }); 54 | 55 | resizeObserver.observe(element); 56 | } 57 | 58 | observeHeader(); 59 | 60 | }); 61 | -------------------------------------------------------------------------------- /src/scripts/share-url-wc.js: -------------------------------------------------------------------------------- 1 | class ShareUrl extends HTMLElement { 2 | 3 | // Setup 4 | constructor() { 5 | super(); 6 | 7 | this.options = { 8 | selector: '.share-url', 9 | activeClass: 'is-active', 10 | action: 'share', 11 | url: document.location.href, 12 | title: document.title, 13 | urlParameter: 'url', 14 | titleParameter: 'text', 15 | textSelector: null, 16 | textLabel: '', 17 | textSuccess: 'Shared', 18 | maintainSize: false, 19 | } 20 | 21 | this.platforms = [{ name: 'bluesky', url: 'https://bsky.app/intent/compose', urlParameter: '' }, { name: 'facebook', url: 'https://facebook.com/sharer/sharer.php', titleParameter: 't', urlParameter: 'u' }, { name: 'linkedin', url: 'https://www.linkedin.com/shareArticle?mini=true' }, { name: 'reddit', url: 'https://www.reddit.com/submit', titleParameter: 'title' }, { name: 'twitter', url: 'https://twitter.com/intent/tweet' }, { name: 'threads', url: 'https://www.threads.net/intent/post' }]; 22 | 23 | 24 | this.setup(); 25 | } 26 | 27 | connectedCallback() { 28 | this.setup(); 29 | } 30 | 31 | setup() { 32 | if (this._instantiated) return; 33 | 34 | this.element = this.querySelector('button, a'); 35 | if (!this.element) return; 36 | 37 | for (const item of this.getAttributeNames()) { 38 | let prop = this.camelCase(item); 39 | let value = this.checkBoolean(this.getAttribute(item)); 40 | this.options[prop] = value; 41 | } 42 | 43 | if (this.element.href && !this.getAttribute('action')) this.options.action = this.element.href; 44 | 45 | this.textElement = this.querySelector(this.options.textSelector); 46 | if (this.textElement === null) this.textElement = this.element; 47 | if (this.options.textLabel) this.textElement.innerText = this.options.textLabel; 48 | 49 | if (this.options.action === 'share' || this.options.action === 'clipboard') { 50 | navigator[this.options.action] ? this.element.addEventListener('click', () => this.shareEvent()) : this.setFallback(); 51 | } 52 | else { 53 | this.element.addEventListener('click', (event) => this.sharePlatform(event)); 54 | } 55 | 56 | this._instantiated = true; 57 | } 58 | 59 | 60 | // Utilities 61 | checkBoolean(string) { 62 | if (string.toLowerCase() === 'true') return true; 63 | if (string.toLowerCase() === 'false') return false; 64 | return string; 65 | } 66 | 67 | isValidUrl(string) { 68 | try { 69 | new URL(string); 70 | return true; 71 | } 72 | catch (error) { 73 | return false; 74 | } 75 | } 76 | 77 | setFallback() { 78 | if (this.element.querySelector('fallback') !== null) { 79 | this.element.classList.add('is-fallback'); 80 | } 81 | else { 82 | this.style.display = 'none'; 83 | } 84 | }; 85 | 86 | camelCase(text, delimiter = '-') { 87 | const pattern = new RegExp((`${delimiter}([a-z])`), 'g'); 88 | return text.replace(pattern, (match, replacement) => replacement.toUpperCase()); 89 | } 90 | 91 | 92 | // Methods 93 | async shareEvent() { 94 | try { 95 | if (this.options.action === 'share') await navigator.share({ title: this.options.title, text: this.options.title, url: this.options.url }); 96 | if (this.options.action === 'clipboard') await navigator.clipboard.writeText(this.options.url); 97 | 98 | this.shareSuccess(); 99 | } 100 | catch (error) { 101 | if (error.name !== 'AbortError') console.error(error.name, error.message); 102 | } 103 | } 104 | 105 | sharePlatform(event) { 106 | event.preventDefault(); 107 | 108 | let platformData = this.platforms.find((item) => item.name === this.options.action); 109 | if (platformData) { 110 | this.options.action = platformData.url; 111 | this.options.urlParameter = platformData.urlParameter ?? this.options.urlParameter; 112 | this.options.titleParameter = platformData.titleParameter ?? this.options.titleParameter; 113 | } 114 | 115 | if (this.options.action === 'mastodon') { 116 | let mastodonInstance = localStorage.getItem('mastodon-instance'); 117 | 118 | if (!mastodonInstance) { 119 | let mastodonPrompt = prompt('Enter your Mastodon instance'); 120 | if (mastodonPrompt === '' || mastodonPrompt === null) return; 121 | 122 | localStorage.setItem('mastodon-instance', mastodonPrompt); 123 | mastodonInstance = localStorage.getItem('mastodon-instance'); 124 | } 125 | 126 | this.options.action = `https://${mastodonInstance}/share`; 127 | } 128 | 129 | if (!this.isValidUrl(this.options.action)) return; 130 | 131 | const platformURL = new URL(this.options.action); 132 | 133 | if (this.options.urlParameter === '') { 134 | this.options.title += ` ${this.options.url}`; 135 | } 136 | else { 137 | platformURL.searchParams.append(this.options.urlParameter, this.options.url); 138 | } 139 | 140 | platformURL.searchParams.append(this.options.titleParameter, this.options.title); 141 | 142 | window.open(platformURL.href, '_blank', 'noreferrer,noopener'); 143 | this.shareSuccess(); 144 | } 145 | 146 | shareSuccess() { 147 | let textWidth = this.textElement.offsetWidth; 148 | this.textElement.innerText = this.options.textSuccess; 149 | if (this.options.maintainSize) this.textElement.style.width = `${Math.max(textWidth, this.textElement.offsetWidth)}px`; 150 | this.element.classList.add(this.options.activeClass); 151 | } 152 | 153 | } 154 | 155 | customElements.define('share-url', ShareUrl); 156 | export { ShareUrl }; 157 | -------------------------------------------------------------------------------- /src/scripts/share-url.js: -------------------------------------------------------------------------------- 1 | const ShareUrl = function (args) { 2 | const defaults = { 3 | selector: '.share-url', 4 | activeClass: 'is-active', 5 | action: 'share', 6 | url: document.location.href, 7 | title: document.title, 8 | urlParameter: 'url', 9 | titleParameter: 'text', 10 | textSelector: null, 11 | textLabel: '', 12 | textSuccess: 'Shared', 13 | maintainSize: false, 14 | } 15 | 16 | let platforms = [{ name: 'bluesky', url: 'https://bsky.app/intent/compose', urlParameter: '' }, { name: 'facebook', url: 'https://facebook.com/sharer/sharer.php', titleParameter: 't', urlParameter: 'u' }, { name: 'linkedin', url: 'https://www.linkedin.com/shareArticle?mini=true' }, { name: 'reddit', url: 'https://www.reddit.com/submit', titleParameter: 'title' }, { name: 'twitter', url: 'https://twitter.com/intent/tweet' }, { name: 'threads', url: 'https://www.threads.net/intent/post' }]; 17 | 18 | let options = {...defaults, ...args}; 19 | 20 | let element; 21 | let textElement; 22 | 23 | 24 | 25 | // Utilities 26 | const checkBoolean = function (string) { 27 | if (string.toLowerCase() === 'true') return true; 28 | if (string.toLowerCase() === 'false') return false; 29 | return string; 30 | }; 31 | 32 | const isValidUrl = function (string) { 33 | try { 34 | new URL(string); 35 | return true; 36 | } 37 | catch (error) { 38 | return false; 39 | } 40 | }; 41 | 42 | const setFallback = function() { 43 | if (element.querySelector('fallback') !== null) { 44 | element.classList.add('is-fallback'); 45 | } 46 | else { 47 | element.style.display = 'none'; 48 | } 49 | }; 50 | 51 | 52 | // Methods 53 | const shareSuccess = function() { 54 | let textWidth = textElement.offsetWidth; 55 | textElement.innerText = options.textSuccess; 56 | if (options.maintainSize) textElement.style.width = `${Math.max(textWidth, textElement.offsetWidth)}px`; 57 | element.classList.add(options.activeClass); 58 | }; 59 | 60 | const shareEvent = async () => { 61 | try { 62 | if (options.action === 'share') await navigator.share({ title: options.title, text: options.title, url: options.url }); 63 | if (options.action === 'clipboard') await navigator.clipboard.writeText(options.url); 64 | 65 | shareSuccess(); 66 | } 67 | catch (error) { 68 | if (error.name !== 'AbortError') console.error(error.name, error.message); 69 | } 70 | }; 71 | 72 | const sharePlatform = function (event) { 73 | event.preventDefault(); 74 | 75 | let platformData = platforms.find((item) => item.name === options.action); 76 | if (platformData) { 77 | options.action = platformData.url; 78 | options.urlParameter = platformData.urlParameter ?? options.urlParameter; 79 | options.titleParameter = platformData.titleParameter ?? options.titleParameter; 80 | } 81 | 82 | if (options.action === 'mastodon') { 83 | let mastodonInstance = localStorage.getItem('mastodon-instance'); 84 | 85 | if (!mastodonInstance) { 86 | let mastodonPrompt = prompt('Enter your Mastodon instance'); 87 | if (mastodonPrompt === '' || mastodonPrompt === null) return; 88 | 89 | localStorage.setItem('mastodon-instance', mastodonPrompt); 90 | mastodonInstance = localStorage.getItem('mastodon-instance'); 91 | } 92 | 93 | options.action = `https://${mastodonInstance}/share`; 94 | } 95 | 96 | if (!isValidUrl(options.action)) return; 97 | 98 | const platformURL = new URL(options.action); 99 | 100 | if (options.urlParameter === '') { 101 | options.title += ` ${options.url}`; 102 | } 103 | else { 104 | platformURL.searchParams.append(options.urlParameter, options.url); 105 | } 106 | 107 | platformURL.searchParams.append(options.titleParameter, options.title); 108 | 109 | window.open(platformURL.href, '_blank', 'noreferrer,noopener'); 110 | shareSuccess(); 111 | }; 112 | 113 | 114 | // Setup 115 | const setup = function() { 116 | let datasetOptions = {...element.dataset}; 117 | let datasetPrefix = 'share'; 118 | 119 | for (const item in datasetOptions) { 120 | if (!item.startsWith(datasetPrefix)) continue; 121 | 122 | let prop = item.substring(datasetPrefix.length); 123 | prop = prop.charAt(0).toLowerCase() + prop.substring(1); 124 | let value = checkBoolean(datasetOptions[item]); 125 | 126 | options[prop] = value; 127 | }; 128 | 129 | if (element.href && !element.dataset.shareAction) options.action = element.href; 130 | 131 | textElement = element.querySelector(options.textSelector); 132 | if (textElement === null) textElement = element; 133 | if (options.textLabel) textElement.innerText = options.textLabel; 134 | 135 | if (options.action === 'share' || options.action === 'clipboard') { 136 | navigator[options.action] ? element.addEventListener('click', () => shareEvent()) : setFallback(); 137 | } 138 | else { 139 | element.addEventListener('click', (event) => sharePlatform(event)); 140 | } 141 | }; 142 | 143 | const init = function() { 144 | element = (typeof options.selector === 'string') ? document.querySelector(options.selector) : options.selector; 145 | if (!element) return; 146 | 147 | setup(); 148 | }; 149 | 150 | init(); 151 | 152 | }; 153 | 154 | 155 | const ShareUrlAuto = function () { 156 | const elements = document.querySelectorAll('.share-url'); 157 | for (const item of elements) { 158 | ShareUrl({ selector: item }); 159 | }; 160 | }; 161 | 162 | 163 | export { ShareUrl, ShareUrlAuto }; 164 | -------------------------------------------------------------------------------- /src/styles/main.css: -------------------------------------------------------------------------------- 1 | /* https://www.joshwcomeau.com/css/custom-css-reset/, https://andy-bell.co.uk/a-more-modern-css-reset/ */ 2 | *, *::before, *::after { 3 | box-sizing: border-box; 4 | } 5 | * { 6 | margin: 0; 7 | } 8 | html { 9 | -moz-text-size-adjust: none; 10 | -webkit-text-size-adjust: none; 11 | text-size-adjust: none; 12 | } 13 | body { 14 | line-height: 1.5; 15 | -webkit-font-smoothing: antialiased; 16 | 17 | min-height: 100vh; 18 | } 19 | img, picture, video, canvas, svg { 20 | display: block; 21 | max-width: 100%; 22 | } 23 | input, button, textarea, select { 24 | font: inherit; 25 | } 26 | p, h1, h2, h3, h4, h5, h6 { 27 | word-wrap: break-word; 28 | } 29 | /* Opinionated */ 30 | h1, h2, h3, h4, 31 | button, input, label { 32 | line-height: 1.2; 33 | } 34 | h1, h2, h3, h4 { 35 | text-wrap: balance; 36 | } 37 | @font-face { 38 | font-family: 'Source Code Pro'; 39 | src: url('../fonts/source-code-pro.woff2') format('woff2'); 40 | display: swap; 41 | } 42 | @font-face { 43 | font-family: 'Work Sans'; 44 | src: url('../fonts/work-sans.woff2') format('woff2'); 45 | display: swap; 46 | } 47 | :root { 48 | /* Colours */ 49 | /* https://oklch-palette.vercel.app/#53.67,0.257,262.51,100, https://oklch.com/#53.67,0.257,262.51,100 */ 50 | --color-50: rgb(245, 248, 255); 51 | --color-100: rgb(223, 234, 255); 52 | --color-200: rgb(153, 189, 255); 53 | --color-300: rgb(109, 159, 255); 54 | --color-400: rgb(63, 126, 255); 55 | --color-500: rgb(1, 87, 255); 56 | --color-600: rgb(0, 72, 215); 57 | --color-700: rgb(0, 52, 163); 58 | --color-800: rgb(0, 33, 112); 59 | --color-900: rgb(0, 17, 69); 60 | 61 | --color-accent-500: rgb(40, 209, 180); 62 | --color-accent-700: rgb(1, 119, 101); 63 | 64 | /* Type */ 65 | --sans-serif-font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, Arial, sans-serif; 66 | --serif-font-family: 'Times New Roman', Times, serif; 67 | /* --body-font-family: 'Work Sans', var(--sans-serif-font-family); 68 | --heading-font-family: 'Source Code Pro', var(--sans-serif-font-family); */ 69 | --body-font-family: 'Work Sans', sans-serif; 70 | --heading-font-family: 'Source Code Pro', monospace; 71 | 72 | --text-color: var(--color-700); 73 | --link-color: var(--color-500); 74 | --link-color-hover: var(--color-700); 75 | 76 | /* Layout */ 77 | --layout-breakpoint-xs: 0; 78 | --layout-breakpoint-sm: 576px; 79 | --layout-breakpoint-md: 768px; 80 | --layout-breakpoint-lg: 992px; 81 | --layout-breakpoint-xl: 1200px; 82 | --layout-breakpoint-xxl: 1400px; 83 | 84 | --layout-gutter-inline: 16px; 85 | --layout-gutter-block: 0px; 86 | 87 | --layout-space-xxs: 4px; 88 | --layout-space-xs: 8px; 89 | --layout-space-sm: 16px; 90 | --layout-space-md: 32px; 91 | --layout-space-lg: 48px; 92 | --layout-space-xl: 64px; 93 | --layout-space-xxl: 80px; 94 | 95 | --ease-out-cubic: cubic-bezier(.215, .610, .355, 1); 96 | --ease-in-out-cubic: cubic-bezier(.65, .05, .36, 1); 97 | 98 | --bg-grid-color: rgba(238, 238, 238, .75); 99 | --bg-grid-line: 2px; 100 | --bg-grid-box: 48px; 101 | } 102 | @supports (color: color(display-p3 0 0 0)) { 103 | :root { 104 | --color-50: rgb(245, 248, 255); 105 | --color-100: rgb(223, 234, 255); 106 | --color-300: rgb(109, 159, 255); 107 | --color-700: rgb(0, 52, 163); 108 | --color-800: rgb(0, 33, 112); 109 | --color-900: rgb(0, 17, 69); 110 | } 111 | 112 | @media (color-gamut: p3) { 113 | :root { 114 | --color-50: color(display-p3 0.96281 0.97222 0.99781); 115 | --color-100: color(display-p3 0.8822 0.91623 0.99269); 116 | --color-300: color(display-p3 0.46998 0.61827 0.97252); 117 | --color-700: color(display-p3 0.07062 0.20014 0.6152); 118 | --color-800: color(display-p3 0.03454 0.12659 0.42217); 119 | --color-900: color(display-p3 0.01272 0.0648 0.25957); 120 | } 121 | } 122 | } 123 | @media (min-width: 768px) { 124 | :root { 125 | --layout-gutter-inline: 24px; 126 | } 127 | } 128 | @supports not (background-color: oklch(0%, 0, 0)) { 129 | :root { 130 | /* --color-50: #f0f5ff; */ 131 | --color-50: #f5f8ff; 132 | /* --color-100: #c5d9ff; */ 133 | --color-100: #dfeaff; 134 | --color-200: #99bdff; 135 | --color-300: #6d9fff; 136 | --color-400: #3f7eff; 137 | --color-500: #0157ff; 138 | --color-600: #0048d7; 139 | --color-700: #0034a3; 140 | --color-800: #002170; 141 | --color-900: #001145; 142 | 143 | --color-accent-500: #28d1b4; 144 | --color-accent-700: #007765; 145 | } 146 | } 147 | /* Custom media queries */ 148 | /* Type and background */ 149 | body { 150 | font-family: 'Work Sans', sans-serif; 151 | font-family: var(--body-font-family); 152 | color: rgb(0, 52, 163); 153 | color: color(display-p3 0.07062 0.20014 0.6152); 154 | color: var(--color-700); 155 | background-color: #fff; 156 | background-image: repeating-linear-gradient(90deg, rgba(238, 238, 238, .75) 0, rgba(238, 238, 238, .75) 2px, transparent 0, transparent 50%), repeating-linear-gradient(180deg, rgba(238, 238, 238, .75) 0, rgba(238, 238, 238, .75) 2px, transparent 0, transparent 50%); 157 | background-image: repeating-linear-gradient(90deg, var(--bg-grid-color) 0, var(--bg-grid-color) var(--bg-grid-line), transparent 0, transparent 50%), repeating-linear-gradient(180deg, var(--bg-grid-color) 0, var(--bg-grid-color) var(--bg-grid-line), transparent 0, transparent 50%); 158 | background-size: 48px 48px; 159 | background-size: var(--bg-grid-box) var(--bg-grid-box); 160 | background-position: calc(50% - (2px/2)) top; 161 | background-position: calc(50% - (var(--bg-grid-line)/2)) top; 162 | } 163 | .content { 164 | background-color: #fff; 165 | border-left: calc(2px/2) solid rgba(238, 238, 238, .75); 166 | border-right: calc(2px/2) solid rgba(238, 238, 238, .75); 167 | border-left: calc(var(--bg-grid-line)/2) solid var(--bg-grid-color); 168 | border-right: calc(var(--bg-grid-line)/2) solid var(--bg-grid-color); 169 | } 170 | ::-moz-selection { 171 | color: #fff; 172 | background-color: rgb(0, 52, 163); 173 | background-color: color(display-p3 0.07062 0.20014 0.6152); 174 | background-color: var(--color-700); 175 | } 176 | ::selection { 177 | color: #fff; 178 | background-color: rgb(0, 52, 163); 179 | background-color: color(display-p3 0.07062 0.20014 0.6152); 180 | background-color: var(--color-700); 181 | } 182 | h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6 { 183 | font-family: 'Source Code Pro', monospace; 184 | font-family: var(--heading-font-family); 185 | font-weight: 400; 186 | word-spacing: -.5ch; 187 | } 188 | h1, h2, h3 { 189 | letter-spacing: -.025em; 190 | } 191 | h1, h2 { 192 | font-weight: 600; 193 | } 194 | /* Clamp from 375 - 768px */ 195 | h1 { font-size: 3rem; font-size: clamp(2.5rem, 2.0229rem + 2.0356vw, 3rem); } 196 | h2 { font-size: 2rem; font-size: clamp(1.5rem, 1.0229rem + 2.0356vw, 2rem); } 197 | h3 { font-size: 1.5rem; font-size: clamp(1.25rem, 1.0115rem + 1.0178vw, 1.5rem); } 198 | h4 { font-size: 1.25rem; font-size: clamp(1.125rem, 1.0057rem + 0.5089vw, 1.25rem); } 199 | h5 { font-size: 1rem; } 200 | h6 { font-size: 1rem; } 201 | p { 202 | 203 | } 204 | ul, ol { 205 | padding-left: 32px; 206 | padding-left: var(--layout-space-md); 207 | } 208 | pre, code { 209 | font-family: 'Source Code Pro', monospace; 210 | font-family: var(--heading-font-family); 211 | } 212 | a { 213 | color: rgb(1, 87, 255); 214 | color: var(--link-color); 215 | transition: color 0.3s; 216 | } 217 | a:hover, a:focus { 218 | color: rgb(0, 52, 163); 219 | color: color(display-p3 0.07062 0.20014 0.6152); 220 | color: var(--link-color-hover); 221 | } 222 | a:focus-within { 223 | outline: 2px solid rgb(1, 87, 255); 224 | outline: 2px solid var(--link-color); 225 | } 226 | /* Elements and utilities */ 227 | html { 228 | scroll-padding-block-start: var(--header-height); 229 | scroll-behavior: smooth; 230 | } 231 | [id]:not(.fullwidth) { 232 | scroll-margin-block-start: 1ex; 233 | } 234 | .img-fluid { 235 | max-width: 100%; 236 | height: auto; 237 | } 238 | .w-100 { 239 | width: 100%; 240 | } 241 | code { 242 | padding: .125rem; 243 | background-color: rgb(245, 248, 255); 244 | background-color: color(display-p3 0.96281 0.97222 0.99781); 245 | background-color: var(--color-50); 246 | } 247 | pre code { 248 | display: block; 249 | padding: 1rem; 250 | white-space: pre; 251 | overflow: auto; 252 | border: 1px solid rgb(1, 87, 255); 253 | border: 1px solid var(--color-500); 254 | } 255 | .table-outer { 256 | display: block; 257 | width: 100%; 258 | } 259 | .table { 260 | width: 100%; 261 | border-collapse: collapse; 262 | } 263 | .table th, .table td { 264 | padding: 16px; 265 | padding: var(--layout-space-sm); 266 | text-align: left; 267 | vertical-align: top; 268 | border: 1px solid rgb(1, 87, 255); 269 | border: 1px solid var(--color-500); 270 | } 271 | .table th { 272 | background-color: rgb(223, 234, 255); 273 | background-color: color(display-p3 0.8822 0.91623 0.99269); 274 | background-color: var(--color-100); 275 | } 276 | /* Scroll shadow for inline overflow */ 277 | /* Inspired by https://daverupert.com/2023/08/animation-timeline-scroll-shadows/ */ 278 | .scroll-shadow-inline { 279 | --shadow-color: rgba(0, 0, 0, 0.2); 280 | --shadow-size: 8px; 281 | --shadow-spread: calc(var(--shadow-size) * -.5); 282 | 283 | overflow-x: auto; 284 | overflow-inline: auto; 285 | 286 | animation: scroll-shadow-inset linear; 287 | scroll-timeline: --scroll-shadow-timeline inline; 288 | animation-timeline: --scroll-shadow-timeline; 289 | 290 | /* This is shorthand for the above using an anonymous timeline instead of a named one */ 291 | /* animation-timeline: scroll(self inline); */ 292 | 293 | /* Non-essential styles */ 294 | border: 1px solid rgb(1, 87, 255); 295 | border: 1px solid var(--color-500); 296 | 297 | /* Stops child elements with a background appearing above the shadow */ 298 | } 299 | .scroll-shadow-inline > * { 300 | mix-blend-mode: multiply; 301 | } 302 | /* Fallback */ 303 | @supports not (animation-timeline: scroll(self inline)) { 304 | .scroll-shadow-inline { 305 | /* Background color should be the same as the element */ 306 | --scroll-bg-color: #fff; 307 | background-image: 308 | linear-gradient(to right, #fff, #fff), linear-gradient(to right, #fff, #fff), 309 | linear-gradient(to right, var(--shadow-color), transparent), linear-gradient(to left, var(--shadow-color), transparent); 310 | background-image: 311 | linear-gradient(to right, var(--scroll-bg-color), var(--scroll-bg-color)), linear-gradient(to right, var(--scroll-bg-color), var(--scroll-bg-color)), 312 | linear-gradient(to right, var(--shadow-color), transparent), linear-gradient(to left, var(--shadow-color), transparent); 313 | background-size: 314 | calc(var(--shadow-size) * 4) 100%, calc(var(--shadow-size) * 4) 100%, 315 | calc(var(--shadow-size) * 2) 100%, calc(var(--shadow-size) * 2) 100%; 316 | background-position: left center, right center, left center, right center; 317 | background-attachment: local, local, scroll, scroll; 318 | background-repeat: no-repeat; 319 | } 320 | } 321 | .scroll-shadow-inline.table-outer { 322 | border-left: 2px solid rgb(1, 87, 255); 323 | border-right: 2px solid rgb(1, 87, 255); 324 | border-left: 2px solid oklch(53.67% 0.257 262.51); 325 | border-left: 2px solid var(--color-500); 326 | border-right: 2px solid oklch(53.67% 0.257 262.51); 327 | border-right: 2px solid var(--color-500); 328 | } 329 | .scroll-shadow-inline .table th:first-child, .scroll-shadow-inline .table td:first-child { 330 | border-left: none; 331 | } 332 | .scroll-shadow-inline .table th:last-child, .scroll-shadow-inline .table td:last-child { 333 | border-right: none; 334 | } 335 | code.scroll-shadow-inline { 336 | --scroll-bg-color: var(--color-50); 337 | } 338 | /* Shadow animations */ 339 | /* Right shadow, left shadow. Negative spread to prevent a shadow on the top and bottom of the element */ 340 | @keyframes scroll-shadow-inset { 341 | from { 342 | box-shadow: 343 | inset calc(var(--shadow-size) * -2) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color), 344 | inset calc(var(--shadow-size) * 0) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color); 345 | } 346 | 10%, 90% { 347 | box-shadow: 348 | inset calc(var(--shadow-size) * -1) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color), 349 | inset calc(var(--shadow-size) * 1) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color); 350 | } 351 | to { 352 | box-shadow: 353 | inset calc(var(--shadow-size) * 0) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color), 354 | inset calc(var(--shadow-size) * 2) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color); 355 | } 356 | } 357 | @keyframes scroll-shadow-inline-end { 358 | from { 359 | box-shadow: 360 | calc(var(--shadow-size) * 0) 0 var(--shadow-size) 0 var(--shadow-color); 361 | } 362 | 10%, 90% { 363 | box-shadow: 364 | calc(var(--shadow-size) * 1) 0 var(--shadow-size) 0 var(--shadow-color); 365 | } 366 | to { 367 | box-shadow: 368 | calc(var(--shadow-size) * 2) 0 var(--shadow-size) 0 var(--shadow-color); 369 | } 370 | } 371 | /* Browser support message */ 372 | .unsupported { 373 | display: none; 374 | } 375 | .unsupported.is-active { 376 | display: block; 377 | } 378 | /* Spacing of components */ 379 | .space, .fullwidth { 380 | --layout-space: var(--layout-space-md); 381 | } 382 | .space--zero { 383 | --layout-space: 0; 384 | } 385 | .space--xxs { 386 | --layout-space: var(--layout-space-xxs); 387 | } 388 | .space--xs { 389 | --layout-space: var(--layout-space-xs); 390 | } 391 | .space--sm { 392 | --layout-space: var(--layout-space-sm); 393 | } 394 | .space--md { 395 | --layout-space: var(--layout-space-md); 396 | } 397 | .space--lg { 398 | --layout-space: var(--layout-space-lg); 399 | } 400 | .space--xl { 401 | --layout-space: var(--layout-space-xl); 402 | } 403 | .space--xxl { 404 | --layout-space: var(--layout-space-xxl); 405 | } 406 | .space { 407 | padding: var(--layout-space); 408 | } 409 | .space--block { 410 | padding-left: 0; 411 | padding-right: 0; 412 | } 413 | .space--inline { 414 | padding-top: 0; 415 | padding-bottom: 0; 416 | } 417 | /* Fullwidth */ 418 | .fullwidth { 419 | } 420 | @media (min-width: 576px) { 421 | .main, .footer { 422 | padding-left: 16px; 423 | padding-right: 16px; 424 | padding-left: var(--layout-gutter-inline); 425 | padding-right: var(--layout-gutter-inline); 426 | } 427 | } 428 | .container { 429 | max-width: 768px; 430 | max-width: var(--layout-breakpoint-md); 431 | padding-left: 16px; 432 | padding-right: 16px; 433 | padding-left: var(--layout-gutter-inline); 434 | padding-right: var(--layout-gutter-inline); 435 | margin-left: auto; 436 | margin-right: auto; 437 | } 438 | .fullwidth > .container { 439 | padding-top: var(--layout-space); 440 | padding-bottom: var(--layout-space); 441 | } 442 | /* Grid */ 443 | .row { 444 | display: flex; 445 | flex-wrap: wrap; 446 | row-gap: 16px; 447 | row-gap: var(--layout-gutter-inline); 448 | margin-left: calc(-1 * 16px); 449 | margin-right: calc(-1 * 16px); 450 | margin-left: calc(-1 * var(--layout-gutter-inline)); 451 | margin-right: calc(-1 * var(--layout-gutter-inline)); 452 | } 453 | .row > * { 454 | flex-shrink: 0; 455 | width: 100%; 456 | max-width: 100%; 457 | padding-left: 16px; 458 | padding-right: 16px; 459 | padding-left: var(--layout-gutter-inline); 460 | padding-right: var(--layout-gutter-inline); 461 | } 462 | @media (min-width: 768px) { 463 | .col-md-4 { 464 | flex: 0 0 auto; 465 | width: 33.33333333%; 466 | } 467 | 468 | .col-md-8 { 469 | flex: 0 0 auto; 470 | width: 66.66666667%; 471 | } 472 | } 473 | @media (min-width: 992px) { 474 | .col-lg-4 { 475 | flex: 0 0 auto; 476 | width: 33.33333333%; 477 | } 478 | 479 | .col-lg-8 { 480 | flex: 0 0 auto; 481 | width: 66.66666667%; 482 | } 483 | } 484 | /* Flow */ 485 | .flow > * + * { 486 | margin-top: 1.5rem; 487 | margin-top: var(--flow-space, 1.5rem); 488 | } 489 | /* Large gap before headings and after h1 */ 490 | .flow .heading:has(h1) + * { 491 | --flow-space: var(--layout-space-lg); 492 | } 493 | .flow h1, .flow h2, .flow h3, .flow h4, .flow h1 + * { 494 | --flow-space: var(--layout-space-lg); 495 | } 496 | /* Medium gap if a heading follows a heading */ 497 | .flow h1 + h2, .flow h2 + h3, .flow h3 + h4, .flow .heading + h2, .flow .heading + h3, .flow .heading + h4 { 498 | --flow-space: var(--layout-space-md); 499 | } 500 | /* Small gap directly after heading and inside heading wrapper */ 501 | .flow h2:not(.does-not-exist) + *, .flow h3:not(.does-not-exist) + *, .flow h4:not(.does-not-exist) + *, .flow .heading + *, .flow h2:not(.does-not-exist) + p + *, .flow h3:not(.does-not-exist) + p + *, .flow h4:not(.does-not-exist) + p + *, .flow .heading + p + *, .flow.heading > * + * { 502 | --flow-space: var(--layout-space-sm); 503 | } 504 | /* Group */ 505 | .group, .nav { 506 | --layout-space: var(--layout-space-sm); 507 | 508 | display: flex; 509 | flex-wrap: wrap; 510 | gap: 16px; 511 | gap: var(--layout-space); 512 | } 513 | /* .group--min { 514 | > * > *:first-child { 515 | &, & > *:first-child { 516 | min-width: 144px; 517 | text-align: center; 518 | } 519 | } 520 | } */ 521 | /* Demos */ 522 | .demos .container { 523 | --flow-space: var(--layout-space-lg); 524 | } 525 | /* ----- Buttons ----- */ 526 | .btn { 527 | --padding-inline: var(--layout-space-sm); 528 | --padding-block: var(--layout-space-xs); 529 | 530 | --text-color: #fff; 531 | --bg-color: var(--color-500); 532 | --border-color: var(--color-500); 533 | --border-size: 0px; 534 | 535 | --text-color-hover: #fff; 536 | --bg-color-hover: var(--color-700); 537 | --border-color-hover: var(--color-700); 538 | 539 | display: inline-flex; 540 | gap: .25em; 541 | justify-content: center; 542 | align-items: center; 543 | padding: 8px 16px; 544 | padding: var(--padding-block) var(--padding-inline); 545 | border: 0px solid rgb(1, 87, 255); 546 | border: var(--border-size) solid var(--border-color); 547 | background-color: rgb(1, 87, 255); 548 | background-color: var(--bg-color); 549 | transition: all .25s; 550 | 551 | color: #fff; 552 | 553 | color: var(--text-color); 554 | font-size: 1rem; 555 | line-height: 1.5; 556 | -webkit-text-decoration: none; 557 | text-decoration: none; 558 | text-align: center; 559 | cursor: pointer; 560 | 561 | position: relative; 562 | overflow: hidden; 563 | } 564 | .btn > span:not(.does-not-exist), .btn > .icon { 565 | position: relative; 566 | z-index: 1; 567 | } 568 | .btn > span { 569 | position: relative; 570 | z-index: 1; 571 | 572 | display: inline-flex; 573 | gap: .25em; 574 | justify-content: center; 575 | align-items: center; 576 | } 577 | .btn::after { 578 | content: ""; 579 | position: absolute; 580 | top: 0; 581 | left: 0; 582 | width: 100%; 583 | height: 100%; 584 | z-index: 0; 585 | background-color: var(--bg-color-hover); 586 | scale: 0 1; 587 | transform-origin: 100% 50%; 588 | transition-property: scale; 589 | transition-duration: inherit; 590 | transition-timing-function: cubic-bezier(.215, .610, .355, 1); 591 | transition-timing-function: var(--ease-out-cubic); 592 | } 593 | .btn:hover, .btn:focus, .btn.is-active, a:hover .btn { 594 | color: var(--text-color-hover); 595 | border-color: var(--border-color-hover); 596 | } 597 | .btn:hover::after { 598 | scale: 1; 599 | transform-origin: 0 50%; 600 | } 601 | .btn:focus::after { 602 | scale: 1; 603 | transform-origin: 0 50%; 604 | } 605 | .btn.is-active::after { 606 | scale: 1; 607 | transform-origin: 0 50%; 608 | } 609 | a:hover .btn::after { 610 | scale: 1; 611 | transform-origin: 0 50%; 612 | } 613 | .btn:disabled { 614 | pointer-events: none; 615 | opacity: .75; 616 | } 617 | .btn.disabled { 618 | pointer-events: none; 619 | opacity: .75; 620 | } 621 | .btn .icon, .btn svg { 622 | pointer-events: none; 623 | } 624 | .btn.btn--outline { 625 | --text-color: var(--color-500); 626 | } 627 | .btn--white { 628 | --text-color: var(--color-500); 629 | --bg-color: var(--color-50); 630 | --border-color: #fff; 631 | 632 | --text-color-hover: var(--color-700); 633 | --bg-color-hover: #fff; 634 | --border-color-hover: #fff; 635 | } 636 | .btn--white.btn--outline { 637 | --text-color: #fff; 638 | } 639 | .btn--green { 640 | --bg-color: var(--color-accent-500); 641 | --border-color: var(--color-accent-500); 642 | 643 | --bg-color-hover: var(--color-accent-700); 644 | --border-color-hover: var(--color-accent-700); 645 | } 646 | .btn--green.btn--outline { 647 | --text-color: var(--color-accent-700); 648 | } 649 | .btn--outline, .btn--ghost { 650 | --bg-color: transparent; 651 | --border-size: 1px; 652 | } 653 | .btn--ghost { 654 | --text-color: #fff; 655 | --text-color-hover: var(--color-700); 656 | --bg-color-hover: #fff; 657 | --border-color-hover: #fff; 658 | } 659 | .btn--icon, .btn--round { 660 | --padding-inline: 1rem; 661 | --padding-block: 1rem; 662 | } 663 | .btn--round { 664 | align-items: center; 665 | justify-content: center; 666 | border-radius: 50%; 667 | aspect-ratio: 1 / 1; 668 | } 669 | .btn--icon-multi:not(.is-active) .icon:last-of-type, .btn--icon-multi.is-active .icon:first-of-type { 670 | display: none; 671 | } 672 | .icon { 673 | display: inline-flex; 674 | justify-content: center; 675 | align-items: center; 676 | width: 1em; 677 | height: 1em; 678 | fill: currentColor; 679 | stroke: currentColor; 680 | transition: inherit; 681 | } 682 | .icon use { 683 | transition: inherit; 684 | } 685 | a .icon, button .icon { 686 | pointer-events: none; 687 | } 688 | /* ----- Header and footer ----- */ 689 | .header { 690 | position: sticky; 691 | top: 0; 692 | z-index: 100; 693 | width: 100%; 694 | color: #fff; 695 | background-color: rgb(1, 87, 255); 696 | background-color: var(--color-500); 697 | border-bottom: 1px solid #fff; 698 | } 699 | @media (min-width: 768px) { 700 | .header { 701 | min-height: 80px; 702 | } 703 | } 704 | .header .container { 705 | display: flex; 706 | gap: 16px; 707 | gap: var(--layout-space-sm); 708 | flex-wrap: wrap; 709 | align-items: center; 710 | } 711 | .header a:not(.btn) { 712 | color: #fff; 713 | -webkit-text-decoration: none; 714 | text-decoration: none; 715 | } 716 | .header a:not(.btn):hover, .header a:not(.btn):focus { 717 | color: rgb(223, 234, 255); 718 | color: color(display-p3 0.8822 0.91623 0.99269); 719 | color: var(--color-100); 720 | } 721 | .logo-text { 722 | flex: 1 0 0%; 723 | margin: 0; 724 | font-family: 'Source Code Pro', monospace; 725 | font-family: var(--heading-font-family); 726 | color: #fff; 727 | 728 | /* font-size: 2.5rem; 729 | font-size: clamp(2rem, 1.5229rem + 2.0356vw, 2.5rem); */ 730 | 731 | font-size: 2rem; 732 | font-size: clamp(1.5rem, 1.0229rem + 2.0356vw, 2rem); 733 | } 734 | .header-nav { 735 | margin-left: auto; 736 | } 737 | .header-nav:not(:has(.btn)) { 738 | row-gap: 0; 739 | } 740 | @media (max-width: 575px) { 741 | .header-nav { 742 | width: 100%; 743 | } 744 | } 745 | .header-nav a:not(.btn) { 746 | position: relative; 747 | color: rgb(223, 234, 255); 748 | color: color(display-p3 0.8822 0.91623 0.99269); 749 | color: var(--color-100); 750 | text-transform: uppercase; 751 | 752 | } 753 | .header-nav a:not(.btn)::after { 754 | content: ""; 755 | position: absolute; 756 | bottom: 0; 757 | left: 0; 758 | width: 100%; 759 | height: 2px; 760 | transition: transform .2s ease-in-out; 761 | 762 | z-index: -1; 763 | background-color: currentColor; 764 | transform: scaleX(0); 765 | transform-origin: 100% 50%; 766 | transition-timing-function: cubic-bezier(.65, .05, .36, 1); 767 | transition-timing-function: var(--ease-in-out-cubic); 768 | } 769 | .header-nav a:not(.btn):hover, .header-nav a:not(.btn):focus, .header-nav a.is-active:not(.btn) { 770 | color: #fff; 771 | } 772 | .header-nav a:not(.btn):hover::after { 773 | transform: scaleX(1); 774 | transform-origin: 0 50%; 775 | } 776 | .header-nav a:not(.btn):focus::after { 777 | transform: scaleX(1); 778 | transform-origin: 0 50%; 779 | } 780 | .header-nav a.is-active:not(.btn)::after { 781 | transform: scaleX(1); 782 | transform-origin: 0 50%; 783 | } 784 | @media (max-width: 575px) { 785 | .header-nav .btn { 786 | width: calc(50% - (var(--layout-space) / 2)); 787 | --padding-block: var(--layout-space-xxs); 788 | } 789 | } 790 | .footer { 791 | text-align: center; 792 | } 793 | .footer .container > * { 794 | padding-top: var(--layout-space); 795 | border-top: 2px solid rgb(1, 87, 255); 796 | border-top: 2px solid oklch(53.67% 0.257 262.51); 797 | border-top: 2px solid var(--color-500); 798 | } 799 | .footer .container > *:not(:first-child) { 800 | margin-top: var(--layout-space); 801 | } 802 | .footer .group, .footer .nav { 803 | align-items: center; 804 | justify-content: center; 805 | } 806 | @media (max-width: 575px) { 807 | .footer .share-title { 808 | width: 100%; 809 | } 810 | } 811 | .footer-nav { 812 | row-gap: 8px; 813 | row-gap: var(--layout-space-xs); 814 | font-size: .875rem; 815 | } 816 | /* Page heading */ 817 | .page-intro { 818 | display: flex; 819 | gap: 32px; 820 | gap: var(--layout-space-md); 821 | align-items: center; 822 | } 823 | .page-heading { 824 | flex-grow: 1; 825 | text-wrap: pretty; 826 | } 827 | .page-intro-img { 828 | width: 80px; 829 | height: auto; 830 | border-radius: 50%; 831 | } 832 | @media (min-width: 576px) { 833 | .columns-sm-2 { 834 | -moz-column-count: 2; 835 | column-count: 2; 836 | -moz-column-gap: 32px; 837 | column-gap: 32px; 838 | -moz-column-gap: var(--layout-space-md); 839 | column-gap: var(--layout-space-md); 840 | } 841 | } 842 | .columns-sm-2 > * { 843 | page-break-inside: avoid; 844 | -moz-column-break-inside: avoid; 845 | break-inside: avoid; 846 | text-wrap: pretty; 847 | } 848 | /* Demo */ 849 | /* Social image - 1200x630 */ 850 | /* body { 851 | scale: 1.5; 852 | transform-origin: top center; 853 | } 854 | 855 | .header { 856 | display: flex; 857 | border: none; 858 | 859 | .container { 860 | width: 100%; 861 | } 862 | } 863 | 864 | .header-nav { 865 | display: none; 866 | } 867 | 868 | .intro .container { 869 | display: flex; 870 | height: 340px; 871 | 872 | > *:not(.page-intro) { 873 | display: none; 874 | } 875 | } 876 | 877 | 878 | .page-heading { 879 | font-size: 3rem; 880 | } 881 | 882 | .page-intro-img { 883 | width: 160px; 884 | } */ 885 | /* Portfolio Image */ 886 | /* body { 887 | scale: 1.565; 888 | transform-origin: top center; 889 | } 890 | 891 | .header { 892 | display: none; 893 | } */ 894 | -------------------------------------------------------------------------------- /src/styles/share-url.css: -------------------------------------------------------------------------------- 1 | .share-url, share-url button { 2 | display: inline-flex; 3 | gap: .25em; 4 | justify-content: center; 5 | align-items: center; 6 | padding: 8px 16px; 7 | border: none; 8 | background-color: #0157ff; 9 | transition: all .25s; 10 | 11 | color: #fff; 12 | font-size: 1rem; 13 | line-height: 1.5; 14 | -webkit-text-decoration: none; 15 | text-decoration: none; 16 | text-align: center; 17 | cursor: pointer; 18 | } 19 | 20 | .share-url:hover, .share-url:focus, .share-url.is-active, share-url button:hover, share-url button:focus, share-url button.is-active { 21 | color: #fff; 22 | background-color: #0034a3; 23 | } 24 | 25 | .share-url svg, share-url button svg { 26 | width: 1em; 27 | height: 1em; 28 | fill: currentColor; 29 | stroke: currentColor; 30 | } 31 | 32 | .share-url:not(.is-active) svg:last-of-type, share-url button:not(.is-active) svg:last-of-type, .share-url.is-active svg:first-of-type, share-url button.is-active svg:first-of-type { 33 | display: none; 34 | } 35 | 36 | /* Fallback - If JS is unavailable or action is not available */ 37 | 38 | .share-url fallback a, share-url button fallback a, .share-url fallback a:hover, share-url button fallback a:hover { 39 | color: #fff; 40 | } 41 | 42 | @media (scripting: enabled) { 43 | .share-url:not(.is-fallback) fallback, share-url button:not(.is-fallback) fallback { 44 | display: none; 45 | } 46 | .share-url.is-fallback:has(fallback) > *:not(fallback), share-url button.is-fallback:has(fallback) > *:not(fallback) { 47 | display: none; 48 | } 49 | } 50 | 51 | @media (scripting: none) { 52 | .share-url:has(fallback) > *:not(fallback), share-url button:has(fallback) > *:not(fallback) { 53 | display: none; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/styles/share-url/share-url.css: -------------------------------------------------------------------------------- 1 | .share-url, share-url button { 2 | display: inline-flex; 3 | gap: .25em; 4 | justify-content: center; 5 | align-items: center; 6 | padding: 8px 16px; 7 | border: none; 8 | background-color: #0157ff; 9 | transition: all .25s; 10 | 11 | color: #fff; 12 | font-size: 1rem; 13 | line-height: 1.5; 14 | text-decoration: none; 15 | text-align: center; 16 | cursor: pointer; 17 | 18 | &:is(:hover, :focus, .is-active) { 19 | color: #fff; 20 | background-color: #0034a3; 21 | } 22 | 23 | svg { 24 | width: 1em; 25 | height: 1em; 26 | fill: currentColor; 27 | stroke: currentColor; 28 | } 29 | 30 | &:not(.is-active) svg:last-of-type, &.is-active svg:first-of-type { 31 | display: none; 32 | } 33 | } 34 | 35 | /* Fallback - If JS is unavailable or action is not available */ 36 | .share-url, share-url button { 37 | fallback { 38 | a, a:hover { 39 | color: #fff; 40 | } 41 | } 42 | 43 | @media (scripting: enabled) { 44 | &:not(.is-fallback) { 45 | fallback { 46 | display: none; 47 | } 48 | } 49 | 50 | &:has(fallback).is-fallback { 51 | > *:not(fallback) { 52 | display: none; 53 | } 54 | } 55 | } 56 | 57 | @media (scripting: none) { 58 | &:has(fallback) { 59 | > *:not(fallback) { 60 | display: none; 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/styles/site/base.css: -------------------------------------------------------------------------------- 1 | /* Type and background */ 2 | body { 3 | font-family: var(--body-font-family); 4 | color: var(--color-700); 5 | background-color: #fff; 6 | background-image: repeating-linear-gradient(90deg, var(--bg-grid-color) 0, var(--bg-grid-color) var(--bg-grid-line), transparent 0, transparent 50%), repeating-linear-gradient(180deg, var(--bg-grid-color) 0, var(--bg-grid-color) var(--bg-grid-line), transparent 0, transparent 50%); 7 | background-size: var(--bg-grid-box) var(--bg-grid-box); 8 | background-position: calc(50% - (var(--bg-grid-line)/2)) top; 9 | } 10 | 11 | .content { 12 | background-color: #fff; 13 | border-inline: calc(var(--bg-grid-line)/2) solid var(--bg-grid-color); 14 | } 15 | 16 | ::selection { 17 | color: #fff; 18 | background-color: var(--color-700); 19 | } 20 | 21 | h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6 { 22 | font-family: var(--heading-font-family); 23 | font-weight: 400; 24 | word-spacing: -.5ch; 25 | } 26 | 27 | h1, h2, h3 { 28 | letter-spacing: -.025em; 29 | } 30 | 31 | h1, h2 { 32 | font-weight: 600; 33 | } 34 | 35 | /* Clamp from 375 - 768px */ 36 | h1 { font-size: 3rem; font-size: clamp(2.5rem, 2.0229rem + 2.0356vw, 3rem); } 37 | h2 { font-size: 2rem; font-size: clamp(1.5rem, 1.0229rem + 2.0356vw, 2rem); } 38 | h3 { font-size: 1.5rem; font-size: clamp(1.25rem, 1.0115rem + 1.0178vw, 1.5rem); } 39 | h4 { font-size: 1.25rem; font-size: clamp(1.125rem, 1.0057rem + 0.5089vw, 1.25rem); } 40 | h5 { font-size: 1rem; } 41 | h6 { font-size: 1rem; } 42 | 43 | p { 44 | 45 | } 46 | 47 | ul, ol { 48 | padding-inline-start: var(--layout-space-md); 49 | } 50 | 51 | pre, code { 52 | font-family: var(--heading-font-family); 53 | } 54 | 55 | a { 56 | color: var(--link-color); 57 | transition: color 0.3s; 58 | 59 | &:hover, &:focus { 60 | color: var(--link-color-hover); 61 | } 62 | 63 | &:focus-within { 64 | outline: 2px solid var(--link-color); 65 | } 66 | } 67 | 68 | 69 | /* Elements and utilities */ 70 | html { 71 | scroll-padding-block-start: var(--header-height); 72 | scroll-behavior: smooth; 73 | } 74 | 75 | [id]:not(.fullwidth) { 76 | scroll-margin-block-start: 1ex; 77 | } 78 | 79 | 80 | .img-fluid { 81 | max-width: 100%; 82 | height: auto; 83 | } 84 | 85 | .w-100 { 86 | width: 100%; 87 | } 88 | 89 | 90 | code { 91 | padding: .125rem; 92 | background-color: var(--color-50); 93 | 94 | pre & { 95 | display: block; 96 | padding: 1rem; 97 | white-space: pre; 98 | overflow: auto; 99 | border: 1px solid var(--color-500); 100 | } 101 | } 102 | 103 | 104 | .table-outer { 105 | display: block; 106 | width: 100%; 107 | } 108 | 109 | .table { 110 | width: 100%; 111 | border-collapse: collapse; 112 | 113 | th, td { 114 | padding: var(--layout-space-sm); 115 | text-align: left; 116 | vertical-align: top; 117 | border: 1px solid var(--color-500); 118 | } 119 | 120 | th { 121 | background-color: var(--color-100); 122 | } 123 | } 124 | 125 | 126 | /* Scroll shadow for inline overflow */ 127 | /* Inspired by https://daverupert.com/2023/08/animation-timeline-scroll-shadows/ */ 128 | .scroll-shadow-inline { 129 | --shadow-color: rgb(0 0 0 / .2); 130 | --shadow-size: 8px; 131 | --shadow-spread: calc(var(--shadow-size) * -.5); 132 | 133 | overflow-x: auto; 134 | overflow-inline: auto; 135 | 136 | animation: scroll-shadow-inset linear; 137 | scroll-timeline: --scroll-shadow-timeline inline; 138 | animation-timeline: --scroll-shadow-timeline; 139 | 140 | /* This is shorthand for the above using an anonymous timeline instead of a named one */ 141 | /* animation-timeline: scroll(self inline); */ 142 | 143 | /* Non-essential styles */ 144 | border: 1px solid var(--color-500); 145 | 146 | /* Stops child elements with a background appearing above the shadow */ 147 | > * { 148 | mix-blend-mode: multiply; 149 | } 150 | 151 | /* Fallback */ 152 | @supports not (animation-timeline: scroll(self inline)) { 153 | /* Background color should be the same as the element */ 154 | --scroll-bg-color: #fff; 155 | background-image: 156 | linear-gradient(to right, var(--scroll-bg-color), var(--scroll-bg-color)), linear-gradient(to right, var(--scroll-bg-color), var(--scroll-bg-color)), 157 | linear-gradient(to right, var(--shadow-color), transparent), linear-gradient(to left, var(--shadow-color), transparent); 158 | background-size: 159 | calc(var(--shadow-size) * 4) 100%, calc(var(--shadow-size) * 4) 100%, 160 | calc(var(--shadow-size) * 2) 100%, calc(var(--shadow-size) * 2) 100%; 161 | background-position: left center, right center, left center, right center; 162 | background-attachment: local, local, scroll, scroll; 163 | background-repeat: no-repeat; 164 | } 165 | } 166 | 167 | .scroll-shadow-inline { 168 | &.table-outer { 169 | border-inline: 2px solid var(--color-500); 170 | } 171 | 172 | .table { 173 | th, td { 174 | &:first-child { 175 | border-inline-start: none; 176 | } 177 | 178 | &:last-child { 179 | border-inline-end: none; 180 | } 181 | } 182 | } 183 | 184 | code& { 185 | --scroll-bg-color: var(--color-50); 186 | } 187 | } 188 | 189 | /* Shadow animations */ 190 | /* Right shadow, left shadow. Negative spread to prevent a shadow on the top and bottom of the element */ 191 | @keyframes scroll-shadow-inset { 192 | from { 193 | box-shadow: 194 | inset calc(var(--shadow-size) * -2) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color), 195 | inset calc(var(--shadow-size) * 0) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color); 196 | } 197 | 10%, 90% { 198 | box-shadow: 199 | inset calc(var(--shadow-size) * -1) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color), 200 | inset calc(var(--shadow-size) * 1) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color); 201 | } 202 | to { 203 | box-shadow: 204 | inset calc(var(--shadow-size) * 0) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color), 205 | inset calc(var(--shadow-size) * 2) 0 var(--shadow-size) var(--shadow-spread) var(--shadow-color); 206 | } 207 | } 208 | 209 | @keyframes scroll-shadow-inline-end { 210 | from { 211 | box-shadow: 212 | calc(var(--shadow-size) * 0) 0 var(--shadow-size) 0 var(--shadow-color); 213 | } 214 | 10%, 90% { 215 | box-shadow: 216 | calc(var(--shadow-size) * 1) 0 var(--shadow-size) 0 var(--shadow-color); 217 | } 218 | to { 219 | box-shadow: 220 | calc(var(--shadow-size) * 2) 0 var(--shadow-size) 0 var(--shadow-color); 221 | } 222 | } 223 | 224 | 225 | /* Browser support message */ 226 | .unsupported { 227 | display: none; 228 | 229 | &.is-active { 230 | display: block; 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/styles/site/components.css: -------------------------------------------------------------------------------- 1 | /* ----- Buttons ----- */ 2 | .btn { 3 | --padding-inline: var(--layout-space-sm); 4 | --padding-block: var(--layout-space-xs); 5 | 6 | --text-color: #fff; 7 | --bg-color: var(--color-500); 8 | --border-color: var(--color-500); 9 | --border-size: 0px; 10 | 11 | --text-color-hover: #fff; 12 | --bg-color-hover: var(--color-700); 13 | --border-color-hover: var(--color-700); 14 | 15 | display: inline-flex; 16 | gap: .25em; 17 | justify-content: center; 18 | align-items: center; 19 | padding: var(--padding-block) var(--padding-inline); 20 | border: var(--border-size) solid var(--border-color); 21 | background-color: var(--bg-color); 22 | transition: all .25s; 23 | 24 | color: var(--text-color); 25 | font-size: 1rem; 26 | line-height: 1.5; 27 | text-decoration: none; 28 | text-align: center; 29 | cursor: pointer; 30 | 31 | position: relative; 32 | overflow: hidden; 33 | 34 | > :is(span, .icon) { 35 | position: relative; 36 | z-index: 1; 37 | } 38 | 39 | > span { 40 | position: relative; 41 | z-index: 1; 42 | 43 | display: inline-flex; 44 | gap: .25em; 45 | justify-content: center; 46 | align-items: center; 47 | } 48 | 49 | &::after { 50 | content: ""; 51 | position: absolute; 52 | top: 0; 53 | left: 0; 54 | width: 100%; 55 | height: 100%; 56 | z-index: 0; 57 | background-color: var(--bg-color-hover); 58 | scale: 0 1; 59 | transform-origin: 100% 50%; 60 | transition-property: scale; 61 | transition-duration: inherit; 62 | transition-timing-function: var(--ease-out-cubic); 63 | } 64 | 65 | &:is(:hover, :focus, .is-active), a:hover & { 66 | color: var(--text-color-hover); 67 | border-color: var(--border-color-hover); 68 | 69 | &::after { 70 | scale: 1; 71 | transform-origin: 0 50%; 72 | } 73 | } 74 | 75 | &:is(.disabled, :disabled) { 76 | pointer-events: none; 77 | opacity: .75; 78 | } 79 | 80 | & .icon, & svg { 81 | pointer-events: none; 82 | } 83 | 84 | &.btn--outline { 85 | --text-color: var(--color-500); 86 | } 87 | } 88 | 89 | 90 | .btn--white { 91 | --text-color: var(--color-500); 92 | --bg-color: var(--color-50); 93 | --border-color: #fff; 94 | 95 | --text-color-hover: var(--color-700); 96 | --bg-color-hover: #fff; 97 | --border-color-hover: #fff; 98 | 99 | &.btn--outline { 100 | --text-color: #fff; 101 | } 102 | } 103 | 104 | .btn--green { 105 | --bg-color: var(--color-accent-500); 106 | --border-color: var(--color-accent-500); 107 | 108 | --bg-color-hover: var(--color-accent-700); 109 | --border-color-hover: var(--color-accent-700); 110 | 111 | &.btn--outline { 112 | --text-color: var(--color-accent-700); 113 | } 114 | } 115 | 116 | 117 | .btn--outline, .btn--ghost { 118 | --bg-color: transparent; 119 | --border-size: 1px; 120 | } 121 | 122 | .btn--ghost { 123 | --text-color: #fff; 124 | --text-color-hover: var(--color-700); 125 | --bg-color-hover: #fff; 126 | --border-color-hover: #fff; 127 | } 128 | 129 | 130 | .btn--icon, .btn--round { 131 | --padding-inline: 1rem; 132 | --padding-block: 1rem; 133 | } 134 | 135 | .btn--round { 136 | align-items: center; 137 | justify-content: center; 138 | border-radius: 50%; 139 | aspect-ratio: 1 / 1; 140 | } 141 | 142 | .btn--icon-multi { 143 | &:not(.is-active) .icon:last-of-type, &.is-active .icon:first-of-type { 144 | display: none; 145 | } 146 | } 147 | 148 | 149 | .icon { 150 | display: inline-flex; 151 | justify-content: center; 152 | align-items: center; 153 | width: 1em; 154 | height: 1em; 155 | fill: currentColor; 156 | stroke: currentColor; 157 | transition: inherit; 158 | 159 | & use { 160 | transition: inherit; 161 | } 162 | } 163 | 164 | a, button { 165 | & .icon { 166 | pointer-events: none; 167 | } 168 | } 169 | 170 | 171 | 172 | /* ----- Header and footer ----- */ 173 | .header { 174 | position: sticky; 175 | top: 0; 176 | z-index: 100; 177 | width: 100%; 178 | color: #fff; 179 | background-color: var(--color-500); 180 | border-bottom: 1px solid #fff; 181 | 182 | @media (--viewport-md-up) { 183 | min-height: 80px; 184 | } 185 | 186 | .container { 187 | display: flex; 188 | gap: var(--layout-space-sm); 189 | flex-wrap: wrap; 190 | align-items: center; 191 | } 192 | 193 | a:not(.btn) { 194 | color: #fff; 195 | text-decoration: none; 196 | 197 | &:is(:hover, :focus) { 198 | color: var(--color-100); 199 | } 200 | } 201 | } 202 | 203 | .logo-text { 204 | flex: 1 0 0%; 205 | margin: 0; 206 | font-family: var(--heading-font-family); 207 | color: #fff; 208 | 209 | /* font-size: 2.5rem; 210 | font-size: clamp(2rem, 1.5229rem + 2.0356vw, 2.5rem); */ 211 | 212 | font-size: 2rem; 213 | font-size: clamp(1.5rem, 1.0229rem + 2.0356vw, 2rem); 214 | } 215 | 216 | 217 | .header-nav { 218 | margin-inline-start: auto; 219 | 220 | &:not(:has(.btn)) { 221 | row-gap: 0; 222 | } 223 | 224 | @media (--viewport-sm-down) { 225 | width: 100%; 226 | } 227 | 228 | a:not(.btn) { 229 | position: relative; 230 | color: var(--color-100); 231 | text-transform: uppercase; 232 | 233 | &::after { 234 | content: ""; 235 | position: absolute; 236 | bottom: 0; 237 | left: 0; 238 | width: 100%; 239 | height: 2px; 240 | transition: transform .2s ease-in-out; 241 | 242 | z-index: -1; 243 | background-color: currentColor; 244 | transform: scaleX(0); 245 | transform-origin: 100% 50%; 246 | transition-timing-function: var(--ease-in-out-cubic); 247 | } 248 | 249 | &:is(:hover, :focus, .is-active) { 250 | color: #fff; 251 | 252 | &::after { 253 | transform: scaleX(1); 254 | transform-origin: 0 50%; 255 | } 256 | } 257 | 258 | } 259 | 260 | .btn { 261 | @media (--viewport-sm-down) { 262 | width: calc(50% - (var(--layout-space) / 2)); 263 | --padding-block: var(--layout-space-xxs); 264 | } 265 | } 266 | } 267 | 268 | 269 | .footer { 270 | text-align: center; 271 | 272 | .container > * { 273 | &:not(:first-child) { 274 | margin-block-start: var(--layout-space); 275 | } 276 | padding-block-start: var(--layout-space); 277 | border-block-start: 2px solid var(--color-500); 278 | } 279 | 280 | .group, .nav { 281 | align-items: center; 282 | justify-content: center; 283 | } 284 | 285 | .share-title { 286 | @media (--viewport-sm-down) { 287 | width: 100%; 288 | } 289 | } 290 | } 291 | 292 | .footer-nav { 293 | row-gap: var(--layout-space-xs); 294 | font-size: .875rem; 295 | } 296 | 297 | 298 | /* Page heading */ 299 | .page-intro { 300 | display: flex; 301 | gap: var(--layout-space-md); 302 | align-items: center; 303 | } 304 | 305 | .page-heading { 306 | flex-grow: 1; 307 | text-wrap: pretty; 308 | } 309 | 310 | .page-intro-img { 311 | width: 80px; 312 | height: auto; 313 | border-radius: 50%; 314 | } 315 | 316 | .columns-sm-2 { 317 | @media (--viewport-sm-up) { 318 | column-count: 2; 319 | column-gap: var(--layout-space-md); 320 | } 321 | 322 | > * { 323 | break-inside: avoid; 324 | text-wrap: pretty; 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /src/styles/site/demo.css: -------------------------------------------------------------------------------- 1 | /* Demo */ -------------------------------------------------------------------------------- /src/styles/site/layout.css: -------------------------------------------------------------------------------- 1 | /* Spacing of components */ 2 | .space, .fullwidth { 3 | --layout-space: var(--layout-space-md); 4 | } 5 | 6 | .space--zero { 7 | --layout-space: 0; 8 | } 9 | .space--xxs { 10 | --layout-space: var(--layout-space-xxs); 11 | } 12 | .space--xs { 13 | --layout-space: var(--layout-space-xs); 14 | } 15 | .space--sm { 16 | --layout-space: var(--layout-space-sm); 17 | } 18 | .space--md { 19 | --layout-space: var(--layout-space-md); 20 | } 21 | .space--lg { 22 | --layout-space: var(--layout-space-lg); 23 | } 24 | .space--xl { 25 | --layout-space: var(--layout-space-xl); 26 | } 27 | .space--xxl { 28 | --layout-space: var(--layout-space-xxl); 29 | } 30 | 31 | .space { 32 | padding: var(--layout-space); 33 | } 34 | 35 | .space--block { 36 | padding-inline: 0; 37 | } 38 | 39 | .space--inline { 40 | padding-block: 0; 41 | } 42 | 43 | 44 | 45 | /* Fullwidth */ 46 | .fullwidth { 47 | } 48 | 49 | .main, .footer { 50 | @media (--viewport-sm-up) { 51 | padding-inline: var(--layout-gutter-inline); 52 | } 53 | } 54 | 55 | .container { 56 | max-width: var(--layout-breakpoint-md); 57 | padding-inline: var(--layout-gutter-inline); 58 | margin-inline: auto; 59 | } 60 | 61 | .fullwidth > .container { 62 | padding-block: var(--layout-space); 63 | } 64 | 65 | 66 | /* Grid */ 67 | .row { 68 | display: flex; 69 | flex-wrap: wrap; 70 | row-gap: var(--layout-gutter-inline); 71 | margin-inline: calc(-1 * var(--layout-gutter-inline)); 72 | 73 | & > * { 74 | flex-shrink: 0; 75 | width: 100%; 76 | max-width: 100%; 77 | padding-inline: var(--layout-gutter-inline); 78 | } 79 | } 80 | 81 | @media (--viewport-md-up) { 82 | .col-md-4 { 83 | flex: 0 0 auto; 84 | width: 33.33333333%; 85 | } 86 | 87 | .col-md-8 { 88 | flex: 0 0 auto; 89 | width: 66.66666667%; 90 | } 91 | } 92 | 93 | @media (--viewport-lg-up) { 94 | .col-lg-4 { 95 | flex: 0 0 auto; 96 | width: 33.33333333%; 97 | } 98 | 99 | .col-lg-8 { 100 | flex: 0 0 auto; 101 | width: 66.66666667%; 102 | } 103 | } 104 | 105 | 106 | /* Flow */ 107 | .flow > * + * { 108 | margin-block-start: var(--flow-space, 1.5rem); 109 | } 110 | 111 | .flow { 112 | /* Large gap before headings and after h1 */ 113 | h1, h2, h3, h4, h1 + *, :is(.heading:has(h1)) + * { 114 | --flow-space: var(--layout-space-lg); 115 | } 116 | 117 | /* Medium gap if a heading follows a heading */ 118 | h1 + h2, h2 + h3, h3 + h4, .heading + :is(h2, h3, h4) { 119 | --flow-space: var(--layout-space-md); 120 | } 121 | 122 | /* Small gap directly after heading and inside heading wrapper */ 123 | :is(h2, h3, h4, .heading) + *, :is(h2, h3, h4, .heading) + p + *, &.heading > * + * { 124 | --flow-space: var(--layout-space-sm); 125 | } 126 | } 127 | 128 | 129 | 130 | /* Group */ 131 | .group, .nav { 132 | --layout-space: var(--layout-space-sm); 133 | 134 | display: flex; 135 | flex-wrap: wrap; 136 | gap: var(--layout-space); 137 | } 138 | 139 | /* .group--min { 140 | > * > *:first-child { 141 | &, & > *:first-child { 142 | min-width: 144px; 143 | text-align: center; 144 | } 145 | } 146 | } */ 147 | 148 | 149 | /* Demos */ 150 | .demos .container { 151 | --flow-space: var(--layout-space-lg); 152 | } 153 | -------------------------------------------------------------------------------- /src/styles/site/main.css: -------------------------------------------------------------------------------- 1 | @import "reset.css"; 2 | @import "variables.css"; 3 | @import "base.css"; 4 | @import "layout.css"; 5 | 6 | @import "components.css"; 7 | @import "demo.css"; 8 | 9 | 10 | /* Social image - 1200x630 */ 11 | /* body { 12 | scale: 1.5; 13 | transform-origin: top center; 14 | } 15 | 16 | .header { 17 | display: flex; 18 | border: none; 19 | 20 | .container { 21 | width: 100%; 22 | } 23 | } 24 | 25 | .header-nav { 26 | display: none; 27 | } 28 | 29 | .intro .container { 30 | display: flex; 31 | height: 340px; 32 | 33 | > *:not(.page-intro) { 34 | display: none; 35 | } 36 | } 37 | 38 | 39 | .page-heading { 40 | font-size: 3rem; 41 | } 42 | 43 | .page-intro-img { 44 | width: 160px; 45 | } */ 46 | 47 | 48 | /* Portfolio Image */ 49 | /* body { 50 | scale: 1.565; 51 | transform-origin: top center; 52 | } 53 | 54 | .header { 55 | display: none; 56 | } */ 57 | -------------------------------------------------------------------------------- /src/styles/site/reset.css: -------------------------------------------------------------------------------- 1 | /* https://www.joshwcomeau.com/css/custom-css-reset/, https://andy-bell.co.uk/a-more-modern-css-reset/ */ 2 | *, *::before, *::after { 3 | box-sizing: border-box; 4 | } 5 | * { 6 | margin: 0; 7 | } 8 | 9 | html { 10 | -moz-text-size-adjust: none; 11 | -webkit-text-size-adjust: none; 12 | text-size-adjust: none; 13 | } 14 | 15 | body { 16 | line-height: 1.5; 17 | -webkit-font-smoothing: antialiased; 18 | 19 | min-height: 100vh; 20 | } 21 | img, picture, video, canvas, svg { 22 | display: block; 23 | max-width: 100%; 24 | } 25 | input, button, textarea, select { 26 | font: inherit; 27 | } 28 | p, h1, h2, h3, h4, h5, h6 { 29 | overflow-wrap: break-word; 30 | } 31 | 32 | /* Opinionated */ 33 | h1, h2, h3, h4, 34 | button, input, label { 35 | line-height: 1.2; 36 | } 37 | 38 | h1, h2, h3, h4 { 39 | text-wrap: balance; 40 | } 41 | -------------------------------------------------------------------------------- /src/styles/site/variables.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Source Code Pro'; 3 | src: url('../fonts/source-code-pro.woff2') format('woff2'); 4 | display: swap; 5 | } 6 | 7 | @font-face { 8 | font-family: 'Work Sans'; 9 | src: url('../fonts/work-sans.woff2') format('woff2'); 10 | display: swap; 11 | } 12 | 13 | 14 | :root { 15 | /* Colours */ 16 | /* https://oklch-palette.vercel.app/#53.67,0.257,262.51,100, https://oklch.com/#53.67,0.257,262.51,100 */ 17 | --color-50: oklch(97.89% 0.01 267.36); 18 | --color-100: oklch(93.51% 0.031 263.52); 19 | --color-200: oklch(79.69% 0.102 262.19); 20 | --color-300: oklch(71% 0.151 262.38); 21 | --color-400: oklch(62.26% 0.203 262.51); 22 | --color-500: oklch(53.67% 0.257 262.51); 23 | --color-600: oklch(47.18% 0.226 262.46); 24 | --color-700: oklch(38.31% 0.185 262.52); 25 | --color-800: oklch(29.19% 0.141 262.52); 26 | --color-900: oklch(20.92% 0.101 262.51); 27 | 28 | --color-accent-500: oklch(77.31% 0.136 177.29); 29 | --color-accent-700: oklch(50.9% 0.094 177.27); 30 | 31 | /* Type */ 32 | --sans-serif-font-family: system-ui, Arial, sans-serif; 33 | --serif-font-family: 'Times New Roman', Times, serif; 34 | /* --body-font-family: 'Work Sans', var(--sans-serif-font-family); 35 | --heading-font-family: 'Source Code Pro', var(--sans-serif-font-family); */ 36 | --body-font-family: 'Work Sans', sans-serif; 37 | --heading-font-family: 'Source Code Pro', monospace; 38 | 39 | --text-color: var(--color-700); 40 | --link-color: var(--color-500); 41 | --link-color-hover: var(--color-700); 42 | 43 | /* Layout */ 44 | --layout-breakpoint-xs: 0; 45 | --layout-breakpoint-sm: 576px; 46 | --layout-breakpoint-md: 768px; 47 | --layout-breakpoint-lg: 992px; 48 | --layout-breakpoint-xl: 1200px; 49 | --layout-breakpoint-xxl: 1400px; 50 | 51 | --layout-gutter-inline: 16px; 52 | --layout-gutter-block: 0px; 53 | 54 | --layout-space-xxs: 4px; 55 | --layout-space-xs: 8px; 56 | --layout-space-sm: 16px; 57 | --layout-space-md: 32px; 58 | --layout-space-lg: 48px; 59 | --layout-space-xl: 64px; 60 | --layout-space-xxl: 80px; 61 | 62 | --ease-out-cubic: cubic-bezier(.215, .610, .355, 1); 63 | --ease-in-out-cubic: cubic-bezier(.65, .05, .36, 1); 64 | 65 | --bg-grid-color: rgba(238, 238, 238, .75); 66 | --bg-grid-line: 2px; 67 | --bg-grid-box: 48px; 68 | 69 | @media (--viewport-md-up) { 70 | --layout-gutter-inline: 24px; 71 | } 72 | } 73 | 74 | @supports not (background-color: oklch(0%, 0, 0)) { 75 | :root { 76 | /* --color-50: #f0f5ff; */ 77 | --color-50: #f5f8ff; 78 | /* --color-100: #c5d9ff; */ 79 | --color-100: #dfeaff; 80 | --color-200: #99bdff; 81 | --color-300: #6d9fff; 82 | --color-400: #3f7eff; 83 | --color-500: #0157ff; 84 | --color-600: #0048d7; 85 | --color-700: #0034a3; 86 | --color-800: #002170; 87 | --color-900: #001145; 88 | 89 | --color-accent-500: #28d1b4; 90 | --color-accent-700: #007765; 91 | } 92 | } 93 | 94 | /* Custom media queries */ 95 | @custom-media --viewport-xs-up (min-width: 480px); 96 | @custom-media --viewport-xs-down (max-width: 479px); 97 | @custom-media --viewport-sm-up (min-width: 576px); 98 | @custom-media --viewport-sm-down (max-width: 575px); 99 | @custom-media --viewport-md-up (min-width: 768px); 100 | @custom-media --viewport-md-down (max-width: 767px); 101 | @custom-media --viewport-lg-up (min-width: 992px); 102 | @custom-media --viewport-lg-down (max-width: 991px); 103 | @custom-media --viewport-xl-up (min-width: 1200px); 104 | @custom-media --viewport-xl-down (max-width: 1199px); 105 | @custom-media --viewport-xxl-up (min-width: 1400px); 106 | @custom-media --viewport-xxl-down (max-width: 1399px); 107 | --------------------------------------------------------------------------------