├── .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 |
18 |
19 | Grid
20 |
21 |
22 |
23 | List
24 |
25 |
26 |
27 | Settings
28 |
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 | }
--------------------------------------------------------------------------------