├── static
├── .gitkeep
├── .DS_Store
└── textures
│ ├── 01.webp
│ ├── 02.webp
│ ├── 03.webp
│ └── 04.webp
├── .DS_Store
├── src
├── .DS_Store
├── favicon.ico
├── shaders
│ ├── vertex.glsl
│ └── fragment.glsl
├── MeshItem.js
├── stylesheet
│ ├── style.css
│ └── base.css
├── js
│ └── Loader.js
├── index.html
└── script.js
├── .gitignore
├── package.json
├── vite.config.js
├── README.md
└── LICENSE
/static/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arrlindii/Shader-Image-Transition/HEAD/.DS_Store
--------------------------------------------------------------------------------
/src/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arrlindii/Shader-Image-Transition/HEAD/src/.DS_Store
--------------------------------------------------------------------------------
/src/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arrlindii/Shader-Image-Transition/HEAD/src/favicon.ico
--------------------------------------------------------------------------------
/static/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arrlindii/Shader-Image-Transition/HEAD/static/.DS_Store
--------------------------------------------------------------------------------
/static/textures/01.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arrlindii/Shader-Image-Transition/HEAD/static/textures/01.webp
--------------------------------------------------------------------------------
/static/textures/02.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arrlindii/Shader-Image-Transition/HEAD/static/textures/02.webp
--------------------------------------------------------------------------------
/static/textures/03.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arrlindii/Shader-Image-Transition/HEAD/static/textures/03.webp
--------------------------------------------------------------------------------
/static/textures/04.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arrlindii/Shader-Image-Transition/HEAD/static/textures/04.webp
--------------------------------------------------------------------------------
/src/shaders/vertex.glsl:
--------------------------------------------------------------------------------
1 | varying vec2 vUv;
2 |
3 | void main(){
4 | vec4 modelPosition = modelMatrix * vec4(position, 1.0);
5 | vec4 viewPosition = viewMatrix * modelPosition;
6 | vec4 clipPosition = projectionMatrix * viewPosition;
7 |
8 | gl_Position = clipPosition;
9 |
10 | // varrying
11 | vUv = uv;
12 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "demo-shader-image-reveal",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build"
9 | },
10 | "devDependencies": {
11 | "vite": "^5.3.3",
12 | "vite-plugin-glsl": "^1.3.1",
13 | "vite-plugin-restart": "^0.4.1"
14 | },
15 | "dependencies": {
16 | "fontfaceobserver": "^2.3.0",
17 | "gsap": "^3.12.5",
18 | "lil-gui": "^0.19.2",
19 | "three": "^0.166.1"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import restart from 'vite-plugin-restart'
2 | import glsl from 'vite-plugin-glsl'
3 |
4 | export default {
5 | root: 'src/',
6 | publicDir: '../static/',
7 | base: './',
8 | server:
9 | {
10 | host: true,
11 | open: !('SANDBOX_URL' in process.env || 'CODESANDBOX_HOST' in process.env)
12 | },
13 | build:
14 | {
15 | outDir: '../dist',
16 | emptyOutDir: true,
17 | sourcemap: true
18 | },
19 | plugins:
20 | [
21 | restart({ restart: [ '../static/**', ] }),
22 | glsl()
23 | ],
24 | }
--------------------------------------------------------------------------------
/src/MeshItem.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import vertexShader from './shaders/vertex.glsl'
3 | import fragmentShader from './shaders/fragment.glsl'
4 |
5 | export default function(w, h) {
6 | const geometry = new THREE.PlaneGeometry(w, h, 128, 128)
7 |
8 | const material = new THREE.ShaderMaterial({
9 | vertexShader: vertexShader,
10 | fragmentShader: fragmentShader,
11 | uniforms: {
12 | uProgress: new THREE.Uniform(0.0),
13 | uSize: new THREE.Uniform(new THREE.Vector2(w, h)),
14 | uTexture: new THREE.Uniform(),
15 | },
16 | })
17 |
18 | const mesh = new THREE.Mesh(geometry, material)
19 | return mesh
20 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Shader Image Transition
2 |
3 | Demo for the tutorial on creating dynamic image transitions with WebGL shaders, exploring techniques like circle SDFs, noise patterns, smooth merging, and texture integration.
4 |
5 | 
6 |
7 | [Article on Codrops](https://tympanus.net/codrops/?p=85782)
8 |
9 | [Demo](https://tympanus.net/Tutorials/ShaderImageReveal/)
10 |
11 | ## Installation
12 |
13 | Install dependencies:
14 |
15 | ```
16 | npm install
17 | ```
18 |
19 | Compile the code for development and start a local server:
20 |
21 | ```
22 | npm run dev
23 | ```
24 |
25 | Create the build:
26 |
27 | ```
28 | npm run build
29 | ```
30 |
31 | ## License
32 | [MIT](LICENSE)
33 |
--------------------------------------------------------------------------------
/src/stylesheet/style.css:
--------------------------------------------------------------------------------
1 | *
2 | {
3 | margin: 0;
4 | padding: 0;
5 | }
6 |
7 | html {
8 | background-color: #000000;
9 | }
10 |
11 | .webgl
12 | {
13 | position: fixed;
14 | top: 0;
15 | left: 0;
16 | outline: none;
17 | }
18 |
19 |
20 | .section
21 | {
22 | height: 100vh;
23 | position: relative;
24 | }
25 |
26 | .heading {
27 | text-transform: uppercase;
28 | mix-blend-mode: difference;
29 | position: fixed;
30 | bottom: 1rem;
31 | right: 2rem;
32 | font-size: 12vmin;
33 | font-weight: 500;
34 | }
35 |
36 | /* hide scrollbar */
37 | html { scrollbar-width: none; } /* Firefox */
38 | body { -ms-overflow-style: none; } /* IE and Edge */
39 | body::-webkit-scrollbar, body::-webkit-scrollbar-button { display: none; } /* Chrome */
40 | /* end hide scrollbar */
41 |
--------------------------------------------------------------------------------
/src/js/Loader.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import image1 from '../../static/textures/01.webp'
3 | import image2 from '../../static/textures/02.webp'
4 | import image3 from '../../static/textures/03.webp'
5 | import image4 from '../../static/textures/04.webp'
6 |
7 | export default class Loader {
8 | loadTextures(onComplete) {
9 | const textureLoader = new THREE.TextureLoader()
10 | let loadCount = 0
11 | const imageSources = [image1, image2, image3, image4]
12 | const textures = []
13 |
14 | imageSources.forEach((src, index) => {
15 | const onLoad = (texture) => {
16 | textures[index] = texture
17 |
18 | loadCount += 1
19 | if (loadCount == imageSources.length) {
20 | onComplete(textures)
21 | }
22 | }
23 | textureLoader.load(src, onLoad)
24 | })
25 | }
26 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Arlind Aliu
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 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Shader Image Reveal | Demo | Codrops
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
28 |
29 |
30 |
Image Reveal
31 |
33 |
35 |
37 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/src/shaders/fragment.glsl:
--------------------------------------------------------------------------------
1 | varying vec2 vUv;
2 | uniform float uProgress;
3 | uniform vec2 uSize;
4 | uniform sampler2D uTexture;
5 | #define PI 3.1415926538
6 |
7 |
8 | float noise(vec2 point) {
9 | float frequency = 1.0;
10 | float angle = atan(point.y,point.x) + uProgress * PI;
11 |
12 | float w0 = (cos(angle * frequency) + 1.0) / 2.0; // normalize [0 - 1]
13 | float w1 = (sin(2.*angle * frequency) + 1.0) / 2.0; // normalize [0 - 1]
14 | float w2 = (cos(3.*angle * frequency) + 1.0) / 2.0; // normalize [0 - 1]
15 | float wave = (w0 + w1 + w2) / 3.0; // normalize [0 - 1]
16 | return wave;
17 | }
18 |
19 | float softMax(float a, float b, float k) {
20 | return log(exp(k * a) + exp(k * b)) / k;
21 | }
22 |
23 | float softMin(float a, float b, float k) {
24 | return -softMax(-a, -b, k);
25 | }
26 |
27 | float circleSDF(vec2 pos, float rad) {
28 | float a = sin(uProgress * 0.2) * 0.25; // range -0.25 - 0.25
29 | float amt = 0.5 + a;
30 | float circle = length(pos);
31 | circle += noise(pos) * rad * amt;
32 | return circle;
33 | }
34 |
35 | float radialCircles(vec2 p, float o, float count) {
36 | vec2 offset = vec2(o, o);
37 |
38 | float angle = (2. * PI)/count;
39 | float s = round(atan(p.y, p.x)/angle);
40 | float an = angle * s;
41 | vec2 q = vec2(offset.x * cos(an), offset.y * sin(an));
42 | vec2 pos = p - q;
43 | float circle = circleSDF(pos, 15.0);
44 | return circle;
45 | }
46 |
47 | void main() {
48 | vec4 bg = vec4(vec3(0.0), 0.0);
49 | vec4 texture = texture2D(uTexture,vUv);
50 | vec2 coords = vUv * uSize;
51 | vec2 o1 = vec2(0.5) * uSize;
52 |
53 | float t = pow(uProgress, 2.5); // easing
54 | float radius = uSize.x / 2.0;
55 | float rad = t * radius;
56 | float c1 = circleSDF(coords - o1, rad);
57 |
58 | vec2 p = (vUv - 0.5) * uSize;
59 | float r1 = radialCircles(p, 0.2 * uSize.x, 3.0);
60 | float r2 = radialCircles(p, 0.25 * uSize.x, 3.0);
61 | float r3 = radialCircles(p, 0.45 * uSize.x, 5.0);
62 |
63 | float k = 50.0 / uSize.x;
64 | float circle = softMin(c1, r1, k);
65 | circle = softMin(circle, r2, k);
66 | circle = softMin(circle, r3, k);
67 |
68 | circle = step(circle, rad);
69 | vec4 color = mix(bg, texture, circle);
70 | gl_FragColor = color;
71 | }
--------------------------------------------------------------------------------
/src/script.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import gsap from 'gsap'
3 | import MeshItem from './MeshItem'
4 | import Loader from './js/Loader'
5 |
6 | /**
7 | * Setup
8 | */
9 | const canvas = document.querySelector('canvas.webgl')
10 | const scene = new THREE.Scene()
11 |
12 | const sizes = {
13 | width: window.innerWidth,
14 | height: window.innerHeight
15 | }
16 |
17 | window.addEventListener('resize', () =>
18 | {
19 | sizes.width = window.innerWidth
20 | sizes.height = window.innerHeight
21 | camera.aspect = sizes.width / sizes.height
22 | camera.updateProjectionMatrix()
23 | renderer.setSize(sizes.width, sizes.height)
24 | renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
25 | })
26 |
27 | const camera = new THREE.PerspectiveCamera(35, sizes.width / sizes.height, 0.1, 2000)
28 | camera.position.set(0, 0, 1600)
29 | scene.add(camera)
30 |
31 | const renderer = new THREE.WebGLRenderer({
32 | canvas: canvas,
33 | alpha: true,
34 | })
35 | renderer.setSize(sizes.width, sizes.height)
36 | renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
37 |
38 | /**
39 | * Objects
40 | */
41 | const material = new THREE.MeshBasicMaterial(0xff0000)
42 | const objectsDistance = sizes.height;
43 |
44 | const mesh1 = MeshItem(1000, 667)
45 | const mesh2 = MeshItem(450, 581)
46 | const mesh3 = MeshItem(553, 600)
47 | const mesh4 = MeshItem(600, 600)
48 |
49 | const sectionMeshes = [mesh1, mesh2, mesh3, mesh4]
50 |
51 | for (let i = 0; i < sectionMeshes.length; i++) {
52 | sectionMeshes[i].position.y = -objectsDistance * i
53 | }
54 |
55 | scene.add(mesh1, mesh2, mesh3, mesh4)
56 |
57 | const loader = new Loader()
58 | loader.loadTextures((textures) => {
59 | document.body.classList.remove("loading");
60 |
61 | for (let i = 0; i < sectionMeshes.length; i++) {
62 | sectionMeshes[i].material.uniforms.uTexture.value = textures[i]
63 | }
64 |
65 | observeScroll()
66 | const section = Math.round(scrollY / sizes.height)
67 | onSectionEnter(section)
68 | tick()
69 | })
70 |
71 |
72 | /**
73 | * Scroll
74 | */
75 | let scrollY = window.scrollY
76 | let currentSection = -1
77 |
78 |
79 | // shift
80 | let shift = 0
81 |
82 | const observeScroll = () => {
83 | window.addEventListener('scroll', () => {
84 | scrollY = window.scrollY
85 |
86 | let newSection = scrollY / sizes.height
87 | newSection = Math.round(newSection)
88 |
89 | if (currentSection != newSection) {
90 | shift += newSection - currentSection
91 | currentSection = newSection
92 | onSectionEnter(newSection)
93 | }
94 | })
95 | }
96 |
97 | const onSectionEnter = (section) => {
98 | gsap.to(
99 | sectionMeshes[section].material.uniforms.uProgress,
100 | {
101 | duration: 3.0,
102 | value: 1.0,
103 | }
104 | )
105 | }
106 |
107 | /**
108 | * Tick
109 | */
110 | const clock = new THREE.Clock()
111 | let time = 0
112 | let targetPosY = -scrollY
113 |
114 | const lerp = (a, b, t) => {
115 | return a + (b - a) * t;
116 | }
117 |
118 | const tick = () =>
119 | {
120 | const elapsedTime = clock.getElapsedTime()
121 | const deltaTime = elapsedTime - time
122 | time = elapsedTime
123 |
124 | targetPosY = lerp(targetPosY, -scrollY, 0.1)
125 | camera.position.y = targetPosY
126 | renderer.render(scene, camera)
127 | window.requestAnimationFrame(tick)
128 | }
--------------------------------------------------------------------------------
/src/stylesheet/base.css:
--------------------------------------------------------------------------------
1 | /* Codrops Template */
2 | *,
3 | *::after,
4 | *::before {
5 | box-sizing: border-box;
6 | }
7 |
8 | :root {
9 | font-size: 14px;
10 | --color-text: #fff;
11 | --color-bg: #000;
12 | --color-link: #afafaf;
13 | --color-link-hover: #fff;
14 | --page-padding: 1.5rem;
15 | }
16 |
17 | body {
18 | margin: 0;
19 | color: var(--color-text);
20 | background-color: var(--color-bg);
21 | font-family: "Instrument Sans", Helvetica, serif;
22 | font-optical-sizing: auto;
23 | font-weight: 400;
24 | font-style: normal;
25 | font-variation-settings: "wdth" 100;
26 | -webkit-font-smoothing: antialiased;
27 | -moz-osx-font-smoothing: grayscale;
28 | }
29 |
30 | @media (scripting: enabled) {
31 | .loading {
32 | &::before,
33 | &::after {
34 | content: '';
35 | position: fixed;
36 | z-index: 10000;
37 | }
38 |
39 | &::before {
40 | top: 0;
41 | left: 0;
42 | width: 100%;
43 | height: 100%;
44 | background: var(--color-bg);
45 | }
46 |
47 | &::after {
48 | top: 50%;
49 | left: 50%;
50 | width: 100px;
51 | height: 1px;
52 | margin: 0 0 0 -50px;
53 | background: var(--color-link);
54 | animation: loaderAnim 1.5s ease-in-out infinite alternate forwards;
55 | }
56 |
57 | @keyframes loaderAnim {
58 | 0% {
59 | transform: scaleX(0);
60 | transform-origin: 0% 50%;
61 | }
62 | 50% {
63 | transform: scaleX(1);
64 | transform-origin: 0% 50%;
65 | }
66 | 50.1% {
67 | transform: scaleX(1);
68 | transform-origin: 100% 50%;
69 | }
70 | 100% {
71 | transform: scaleX(0);
72 | transform-origin: 100% 50%;
73 | }
74 | }
75 | }
76 | }
77 |
78 | a {
79 | text-decoration: none;
80 | color: var(--color-link);
81 | outline: none;
82 | cursor: pointer;
83 |
84 | &:hover {
85 | text-decoration: underline;
86 | color: var(--color-link-hover);
87 | }
88 |
89 | &:focus {
90 | outline: none;
91 | background: lightgrey;
92 |
93 | &:not(:focus-visible) {
94 | background: transparent;
95 | }
96 |
97 | &:focus-visible {
98 | outline: 2px solid red;
99 | background: transparent;
100 | }
101 | }
102 | }
103 |
104 | .frame {
105 |
106 | padding: 3rem var(--page-padding) 0;
107 | display: grid;
108 | z-index: 1000;
109 | position: relative;
110 | grid-row-gap: 1rem;
111 | grid-column-gap: 2rem;
112 | pointer-events: none;
113 | justify-items: start;
114 | grid-template-columns: auto auto;
115 | grid-template-areas:
116 | 'title'
117 | 'back'
118 | 'archive'
119 | 'github'
120 | 'tags'
121 | 'sponsor';
122 |
123 | #cdawrap {
124 | justify-self: start;
125 | grid-area: sponsor;
126 | }
127 |
128 | a,
129 | button {
130 | pointer-events: auto;
131 | }
132 |
133 | .frame__title {
134 | grid-area: title;
135 | font-size: inherit;
136 | margin: 0;
137 | }
138 |
139 | .frame__back {
140 | grid-area: back;
141 | justify-self: start;
142 | }
143 |
144 | .frame__archive {
145 | grid-area: archive;
146 | justify-self: start;
147 | }
148 |
149 | .frame__github {
150 | grid-area: github;
151 | }
152 |
153 | .frame__tags {
154 | grid-area: tags;
155 | display: flex;
156 | flex-wrap: wrap;
157 | gap: 1rem;
158 | }
159 |
160 | .frame__demos {
161 | grid-area: demos;
162 | display: flex;
163 | flex-wrap: wrap;
164 | gap: 1rem;
165 | }
166 |
167 | @media screen and (min-width: 53em) {
168 | padding: var(--page-padding);
169 | height: 100%;
170 | position: fixed;
171 | top: 0;
172 | left: 0;
173 | width: 100%;
174 | grid-template-columns: auto auto auto auto 1fr;
175 | grid-template-rows: auto auto;
176 | align-content: space-between;
177 | grid-template-areas:
178 | 'title back github archive tags'
179 | 'sponsor sponsor sponsor ... ...';
180 |
181 | .frame__tags {
182 | justify-self: end;
183 | }
184 |
185 | .frame__demos,
186 | #cdawrap {
187 | align-self: end;
188 | max-width: 300px;
189 | }
190 | }
191 | }
192 |
193 | .content {
194 | padding: var(--page-padding);
195 | display: flex;
196 | flex-direction: column;
197 | width: 100vw;
198 | position: relative;
199 |
200 | @media screen and (min-width: 53em) {
201 | min-height: 100vh;
202 | justify-content: center;
203 | align-items: center;
204 | }
205 | }
206 |
--------------------------------------------------------------------------------