├── .gitignore ├── README.md ├── app ├── index.css ├── index.html ├── index.js ├── pointer-feedback.element.js ├── tab.animator.js ├── tab.controller.js └── tab.styles.css ├── package-lock.json ├── package.json ├── postcss.config.js └── rollup.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | app/bundle.css 3 | app/bundle.js 4 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Modern Javascript › 2 | [Rollup](https://rollupjs.org) to **bundle**, **treeshake**, **import from NPM or URLs**, and **import CSS**. 3 | 4 | ### Modern CSS › 5 | [PostCSS](https://postcss.org) to **import from NPM**, [postcss-preset-env](https://preset-env.cssdb.org/) for **CSS features from the spec**, and **easings** from [easings.net](https://easings.net) for convenient use in animations. 6 | 7 | ### Rad Development Server › 8 | [Browsersync](https://www.browsersync.io) with all the goodies: **live reload**, **cross device syncing**, **remote debugging**, [etc](https://www.browsersync.io). 9 | 10 |

11 | 12 | ## Getting Started 13 | 1. `mkdir new-project-name && cd $_` 14 | 1. `git clone --depth=1 https://github.com/argyleink/shortstack.git . && rm -rf ./.git` 15 | 1. `npm i` 16 | 1. `npm start` 17 | -------------------------------------------------------------------------------- /app/index.css: -------------------------------------------------------------------------------- 1 | @import 'tab.styles.css'; 2 | 3 | body { 4 | margin: 0; 5 | background: hsl(0 0% 90%); 6 | color: hsl(0 0% 5%); 7 | min-height: 100vh; 8 | font-family: system-ui; 9 | display: grid; 10 | place-items: center; 11 | } 12 | 13 | h2 { 14 | font-size: 3rem; 15 | font-weight: lighter; 16 | margin-top: 0; 17 | } 18 | 19 | p { 20 | font-size: 1.1rem; 21 | font-weight: 300; 22 | line-height: 2; 23 | } 24 | 25 | main { 26 | max-width: 50vw; 27 | 28 | @media (width <= 720px) { 29 | max-width: 100%; 30 | } 31 | } 32 | 33 | .grid, .list { 34 | display: grid; 35 | gap: 1rem; 36 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 37 | grid-auto-rows: 200px; 38 | 39 | & > figure { 40 | margin: 0; 41 | width: 100%; 42 | height: 100%; 43 | background: hsl(200 20% 92%); 44 | border-radius: .5rem; 45 | } 46 | } 47 | 48 | .list { 49 | grid-template-columns: 1fr; 50 | grid-auto-rows: 5rem; 51 | } -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Houdini Tabs 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | 21 | 25 | 29 |
30 | 31 |
32 | 33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | 48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | 62 |
63 |

Leverage agile frameworks to provide a robust synopsis for high level overviews. Iterative approaches to corporate strategy foster collaborative thinking to further the overall value proposition.

64 |

Organically grow the holistic world view of disruptive innovation via workplace diversity and empowerment.

65 |
66 |
67 |
68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import './tab.controller.js' 2 | import './pointer-feedback.element.js' -------------------------------------------------------------------------------- /app/pointer-feedback.element.js: -------------------------------------------------------------------------------- 1 | const STYLES = ` 2 | :host { 3 | display: block; 4 | position: absolute; 5 | top: 0; left: 0; right: 0; bottom: 0; 6 | overflow: hidden; 7 | z-index: -1; 8 | contain: layout; 9 | --ink-ripple_accent-color: hsl(0,0%,80%); 10 | --ink-ripple_scale-speed: 300ms; 11 | --ink-ripple_transparency-speed: 200ms; 12 | } 13 | 14 | :host::before { 15 | position: absolute; 16 | display: block; 17 | content: ''; 18 | border-radius: 50%; 19 | background: var(--ink-ripple_accent-color); 20 | opacity: 0; 21 | transform: scale(0); 22 | } 23 | 24 | :host([animatable])::before { 25 | transition: opacity var(--ink-ripple_transparency-speed) linear, transform var(--ink-ripple_scale-speed) linear; 26 | } 27 | 28 | :host([mouseup][animatable])::before { 29 | transition: opacity .4s linear, transform .2s linear; 30 | } 31 | 32 | :host([mousedown])::before { 33 | opacity: 1; 34 | transform: scale(1); 35 | } 36 | 37 | :host([mouseup]:not([mousedown]))::before { 38 | opacity: 0; 39 | transform: scale(1); 40 | } 41 | 42 | :host([hidden]) { 43 | display: none; 44 | } 45 | ` 46 | // todo: test getting boundingrect once, and only once 47 | class PointerFeedback extends HTMLElement { 48 | 49 | constructor() { 50 | super() 51 | 52 | this.attachShadow({ 53 | mode: 'open' 54 | }) 55 | 56 | this.shadowRoot 57 | .appendChild(PointerFeedback.template.cloneNode(true)) 58 | 59 | this.styles = this.shadowRoot.querySelector('style') 60 | this.cssAdded = false 61 | } 62 | 63 | static get template() { 64 | if (this.$fragment) 65 | return this.$fragment 66 | 67 | const $fragment = document.createDocumentFragment() 68 | let $styles = document.createElement('style') 69 | $styles.innerHTML = STYLES 70 | 71 | $fragment.appendChild($styles) 72 | 73 | this.$fragment = $fragment 74 | 75 | return $fragment 76 | } 77 | 78 | set animatable(val) { 79 | val 80 | ? this.setAttribute('animatable', '') 81 | : this.removeAttribute('animatable') 82 | } 83 | 84 | set mousedown(val) { 85 | val 86 | ? this.setAttribute('mousedown', '') 87 | : this.removeAttribute('mousedown') 88 | } 89 | 90 | set mouseup(val) { 91 | val 92 | ? this.setAttribute('mouseup', '') 93 | : this.removeAttribute('mouseup') 94 | } 95 | 96 | set disabled(val) { 97 | val 98 | ? this.setAttribute('disabled', '') 99 | : this.removeAttribute('disabled') 100 | } 101 | 102 | get mouseup() { return this.hasAttribute('mouseup') } 103 | get mousedown() { return this.hasAttribute('mousedown') } 104 | get disabled() { return this.hasAttribute('disabled') } 105 | 106 | connectedCallback() { 107 | this.addEventListener('mousedown', ({offsetX, offsetY}) => { 108 | if (this.disabled) return 109 | this._triggerRippleIn(offsetX, offsetY) 110 | }) 111 | 112 | document.documentElement.addEventListener('mouseup', e => { 113 | if (this.disabled) return 114 | this._triggerRippleOut() 115 | }) 116 | 117 | this.parentElement.style.overflow = 'hidden' // todo: try matching parent border radius 118 | this.parentElement.style.willChange = 'transform' 119 | } 120 | 121 | simulateRipple(x = false, y = false) { 122 | if (!x || !y) { 123 | const rect = this.getBoundingClientRect() 124 | x = rect.width / 2 125 | y = rect.height / 2 126 | } 127 | 128 | this._triggerRippleIn(x, y) 129 | 130 | this.addEventListener('transitionend', e => { 131 | if (e.propertyName === 'transform') 132 | this._triggerRippleOut() 133 | }) 134 | } 135 | 136 | _fadeOut() { 137 | this.mousedown = false 138 | 139 | requestAnimationFrame(() => { 140 | this.addEventListener('transitionend', this._transitionOutEnd) 141 | }) 142 | } 143 | 144 | _removeCSS() { 145 | this.styles.sheet.deleteRule(0) 146 | this.cssAdded = false 147 | } 148 | 149 | _reset() { 150 | this.animatable = false 151 | this.mousedown = false 152 | this.mouseup = false 153 | 154 | this.removeEventListener('transitionend', this._transitionOutEnd) 155 | this.removeEventListener('transitionend', this._transitionInEnd) 156 | 157 | if (this.cssAdded) this._removeCSS() 158 | 159 | this.transitionInOver = false 160 | this.transitionOutOver = false 161 | } 162 | 163 | _transitionInEnd(evt) { 164 | if (evt.pseudoElement) { 165 | if (evt.propertyName === 'transform' && !this.transitionInOver) { 166 | this.removeEventListener('transitionend', this._transitionInEnd) 167 | 168 | this.transitionInOver = true 169 | 170 | if (this.mouseup) this._fadeOut() 171 | } 172 | } 173 | } 174 | 175 | _transitionOutEnd(evt) { 176 | if (evt.pseudoElement && evt.propertyName === 'opacity') { 177 | this.transitionOutOver = true 178 | this._reset() 179 | } 180 | } 181 | 182 | _positionPseduoElement(x, y) { 183 | let { height, width } = this.getBoundingClientRect() 184 | 185 | const largest = Math.max(height, width) 186 | 187 | width = largest * 2 + (largest / 2) 188 | height = largest * 2 + (largest / 2) 189 | 190 | const xPos = x - (width / 2) 191 | const yPos = y - (height / 2) 192 | 193 | let speed = largest 194 | 195 | if (speed > 700) speed = 700 196 | if (speed < 300) speed = 300 197 | 198 | this.styles.sheet.insertRule(` 199 | :host:before { 200 | left: ${xPos}px; 201 | top: ${yPos}px; 202 | width: ${width}px; 203 | height: ${height}px; 204 | --ink-ripple_scale-speed: ${speed}ms; 205 | --ink-ripple_transparency-speed: ${speed / 3}ms; 206 | } 207 | `, 0) 208 | 209 | this.cssAdded = true 210 | } 211 | 212 | _triggerRippleIn(offsetX, offsetY) { 213 | this._reset() 214 | this._positionPseduoElement(offsetX, offsetY) 215 | 216 | requestAnimationFrame(() => { 217 | this.addEventListener('transitionend', this._transitionInEnd) 218 | this.animatable = true 219 | this.mousedown = true 220 | }) 221 | } 222 | 223 | _triggerRippleOut() { 224 | if (this.transitionOutOver || !this.mousedown) 225 | return 226 | 227 | this.mouseup = true 228 | 229 | if (this.transitionInOver) 230 | this._fadeOut() 231 | } 232 | 233 | } 234 | 235 | customElements.define('pointer-feedback', PointerFeedback) -------------------------------------------------------------------------------- /app/tab.animator.js: -------------------------------------------------------------------------------- 1 | registerAnimator('current-tab', class { 2 | constructor({count, width}) { 3 | this._offset = 0 4 | this._sections = count 5 | this._width = width 6 | } 7 | 8 | animate(currentTime, effect) { 9 | const delta = Math.abs(currentTime - this._offset) 10 | 11 | // bug, at 1 we disable the animation! 12 | const progress = Math.min(delta / (this._width * this._sections), 0.999) 13 | 14 | effect.localTime = progress * 100 15 | } 16 | }) -------------------------------------------------------------------------------- /app/tab.controller.js: -------------------------------------------------------------------------------- 1 | const loadWorklet = async () => { 2 | await CSS.animationWorklet.addModule('tab.animator.js') 3 | 4 | const tabs = document.querySelector('.material-tabs') 5 | const indicator = tabs.querySelector('.tab-indicator') 6 | const section = tabs.querySelector('section') 7 | const articles = section.querySelectorAll('article') 8 | 9 | const curTabOptions = { 10 | count: articles.length - 1, 11 | width: section.clientWidth, 12 | } 13 | 14 | const scrollTimeline = new ScrollTimeline({ 15 | scrollSource: section, 16 | orientation: 'inline', 17 | timeRange: section.scrollWidth - section.clientWidth, 18 | }) 19 | 20 | const effect = new KeyframeEffect(indicator, 21 | [ 22 | {transform: 'translateX(0)'}, 23 | {transform: `translateX(${curTabOptions.count}00%)`}, 24 | ], 25 | { 26 | duration: 100, 27 | iterations: Infinity, 28 | fill: 'both', 29 | }, 30 | ) 31 | 32 | const animation = new WorkletAnimation('current-tab', 33 | effect, 34 | scrollTimeline, 35 | curTabOptions, 36 | ) 37 | 38 | animation.play() 39 | } 40 | 41 | const listen = () => { 42 | const tabs = document.querySelector('.material-tabs') 43 | const tab_btns = document.querySelectorAll('.material-tabs > header > button') 44 | const snap = document.querySelector('.material-tabs > section') 45 | const snap_width = snap.clientWidth 46 | 47 | tabs.style.setProperty('--sections', tab_btns.length) 48 | 49 | tab_btns.forEach(node => 50 | node.addEventListener('click', tab_clicked)) 51 | 52 | snap.addEventListener('scrollend', e => { 53 | const selection_index = Math.round(e.currentTarget.scrollLeft / snap_width) 54 | tab_btns[selection_index].focus() 55 | }) 56 | } 57 | 58 | const tab_clicked = ({currentTarget}) => { 59 | const index = [...currentTarget.parentElement.children].indexOf(currentTarget) 60 | const tab_article = document.querySelector(`.material-tabs > section > article:nth-child(${index + 1})`) 61 | 62 | document.querySelector(`.material-tabs > section`) 63 | .scrollTo({ 64 | top: 0, 65 | left: tab_article.offsetLeft, 66 | behavior: 'smooth', 67 | }) 68 | } 69 | 70 | listen() 71 | 72 | if (!CSS.animationWorklet) { 73 | console.warn('Missing CSS.animationWorklet. To enable scroll effect please load in HTTPS and enable flag chrome://flags/#enable-experimental-web-platform-features') 74 | document.body.innerHTML = 'Missing CSS.animationWorklet.
To enable demo please enable Chrome flag chrome://flags/#enable-experimental-web-platform-features and load on HTTPS' 75 | } 76 | else { 77 | console.log('🧙‍♂️') 78 | loadWorklet() 79 | } 80 | -------------------------------------------------------------------------------- /app/tab.styles.css: -------------------------------------------------------------------------------- 1 | .material-tabs { 2 | --header-height: 50px; 3 | --accent: hsl(330 100% 71%); 4 | 5 | display: grid; 6 | grid-template-rows: var(--header-height) 4px 1fr; 7 | position: relative; 8 | background: white; 9 | box-shadow: 0 1rem 10rem -3rem hsla(0 0% 0% / 30%); 10 | 11 | overflow: hidden; 12 | border-radius: 1rem; 13 | padding: .25rem; 14 | max-height: 80vh; 15 | 16 | & > header { 17 | display: grid; 18 | gap: .25rem; 19 | grid-auto-flow: column; 20 | grid-auto-columns: 1fr; 21 | place-content: stretch; 22 | 23 | & > button { 24 | --background: white; 25 | --background-highlight: hsl(330 100% 98%); 26 | --text: hsl(330 80% 10%); 27 | 28 | appearance: none; 29 | border: none; 30 | border-radius: .25rem; 31 | text-transform: uppercase; 32 | font-size: 1rem; 33 | background: var(--background); 34 | color: var(--text); 35 | outline-color: var(--accent); 36 | 37 | &:hover { 38 | cursor: pointer; 39 | background: var(--background-highlight); 40 | } 41 | 42 | &:focus:not(:focus-visible) { 43 | cursor: pointer; 44 | background: var(--background-highlight); 45 | } 46 | 47 | &:first-of-type { 48 | border-radius: 1rem .25rem .25rem; 49 | } 50 | 51 | &:last-of-type { 52 | border-radius: .25rem 1rem .25rem .25rem; 53 | } 54 | } 55 | } 56 | 57 | & > .tab-indicator { 58 | background: var(--accent); 59 | width: calc(100% / var(--sections)); 60 | border-radius: .25rem; 61 | } 62 | 63 | & > section { 64 | width: 100%; 65 | overflow-x: auto; 66 | overscroll-behavior-x: contain; 67 | scroll-snap-type: x mandatory; 68 | 69 | display: grid; 70 | grid-auto-flow: column; 71 | grid-auto-columns: 100%; 72 | 73 | &::-webkit-scrollbar { 74 | display: none; 75 | } 76 | 77 | & > article { 78 | scroll-snap-align: start; 79 | scroll-snap-stop: always; 80 | 81 | padding: 1rem; 82 | overflow-y: auto; 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shortstack", 3 | "version": "1.1.0", 4 | "author": "Adam Argyle", 5 | "description": "simple starter", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "npm run concurrent", 9 | "concurrent": "concurrently --kill-others \"npm run dev:js\" \"npm run dev:css\" \"npm run dev:server\"", 10 | "bundle": "concurrently \"rollup -c\" \"postcss app/index.css -o app/bundle.css\"", 11 | "dev:js": "rollup -c -w", 12 | "dev:css": "postcss app/index.css -o app/bundle.css -w", 13 | "dev:server": "browser-sync start --server 'app' --files 'app/index.html,app/bundle.css,app/bundle.js' --no-open" 14 | }, 15 | "license": "ISC", 16 | "dependencies": {}, 17 | "devDependencies": { 18 | "browser-sync": "^2.26.4", 19 | "concurrently": "^4.1.0", 20 | "import-http": "^0.3.1", 21 | "postcss": "^7.0.14", 22 | "postcss-cli": "^6.1.2", 23 | "postcss-easings": "^2.0.0", 24 | "postcss-import": "^12.0.1", 25 | "postcss-import-url": "^4.0.0", 26 | "postcss-loader": "^3.0.0", 27 | "postcss-preset-env": "^6.5.0", 28 | "rollup": "^1.10.1", 29 | "rollup-plugin-node-resolve": "^4.2.3", 30 | "rollup-plugin-postcss": "^2.0.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const postcssPresetEnv = require('postcss-preset-env') 2 | const postcssImport = require('postcss-import') 3 | const postcsseasings = require('postcss-easings') 4 | const importUrl = require('postcss-import-url') 5 | 6 | module.exports = { 7 | plugins: [ 8 | postcsseasings(), 9 | importUrl(), 10 | postcssImport(), 11 | postcssPresetEnv({ 12 | stage: 0, 13 | browsers: [ 14 | '>0.25%', 15 | 'not ie 11', 16 | 'not op_mini all', 17 | ], 18 | }), 19 | ] 20 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | import postcss from 'rollup-plugin-postcss' 3 | 4 | import { default as importHTTP } from 'import-http/rollup' 5 | 6 | export default { 7 | input: 'app/index.js', 8 | output: { 9 | file: 'app/bundle.js', 10 | format: 'es', 11 | sourcemap: 'inline', 12 | }, 13 | plugins: [ 14 | resolve({ 15 | jsnext: true, 16 | }), 17 | importHTTP(), 18 | postcss({ 19 | extract: false, 20 | inject: false, 21 | }) 22 | ], 23 | watch: { 24 | exclude: ['node_modules/**'], 25 | } 26 | } --------------------------------------------------------------------------------