Skip to content
Robb Owen Digital

Robb Owen Digital

Robb is an independent creative developer from South Wales who loves helping organisations to build ambitious yet accessible web projects. Sometimes, he also writes in the third person.

robbowen.digital
Robb Owen DigitalRobb Owen Digital
ANIMATION AND VISUAL EFFECTS

Synthwave '84

Do you remember that endless summer back in '84? Cruising down the ocean-highway with the top down, the wind in our hair and heads buzzing with neon dreams? No, I don't remember it either, but with this experimental theme we can go there. This colour scheme is influenced by the music and the cover artwork of modern Synthwave bands like FM-84, Timecop 1983 and The Midnight. By association, that means I've also taken heavy influence from the excellent retro-tinged artwork of James White (check out his work, it's awesome). ## But ...why? I was a kid in the 80s but for most of my teenage life I strongly disliked nearly everything about the 80s aesthetic of my childhood. It was like, so lame. With the hindsight of recent years though, I've realised that it was actually pretty sweet and I wanted to celebrate it a little. Much the same way, in the modern web-development world of shaders, React and WebGL, I feel like it's easy to forget that the basics are actually pretty damn good. To that end, this theme goes back to basics - No Shader magic. No cloud-streamed WebGL render-farms. Just plain CSS. ## Using SynthWave '84 The theme can be downloaded from the Visual Studio marketplace or from VS code itself, by searching for SynthWave 84 in the extensions tab. If you want to enable the glow, please carefully read the installation instructions for this, and the theme's dependencies. ## Looking into the future So, what's in store for the future of this theme? First and foremost, better language support. I primarily develop with JavaScript, Elixir and Python so whilst the theme looks good for those languages, tweaks have been needed for other languages. There's more to come. Many people have been also excited to see Synthwave '84 ported to other software. Whilst I'm not currently planning to port the theme myself, the community has already produced several fantastic ports: - Synthwave 85 for Rstudio courtesy of Jacqueline Nolis - Synthave '84 for Carbon courtesy of Adam - Hyper terminal courtesy of Diogo Cezar Teixeira Batista. Perhaps most surprisingly for me, Panic, the team behind the legendary Mac editor Coda have recently announced their next-generation editor, called Nova. Turns out that the team at Panic liked Synthwave '84 enough to create their own version, called Outrun. It's looking pretty sweet. Lastly, beyond editor support, the most popular feature-request has been for a light version. To that I can say: It's in the works. Which reminds me, the eagle-eyed amongst you might have noticed that this page is sporting a lovely shade of sunset-peach...
ADAMDIOGO CEZAR TEIXEIRA BATISTA
Robb Owen DigitalRobb Owen Digital
ANIMATION AND VISUAL EFFECTS

Synthwave '84 has changed

It's been a wild ride since I first tweeted about Synthwave '84. The support has been amazing and I'd like to start by saying thanks everyone who has shared or used the theme since the last release. Particularly, I'd like to thank you all for battling through the less than ideal installation process. It's been a bumpy road but it's been truly great to see so many people enjoying the theme. ## Neon Dreams This v0.1.0 update marks a changing point for the theme. As of writing, VS Code still doesn't support text effects in its themes, so the only way to apply the neon glow for Synthwave '84 is to inject code directly into the application markup. The previous versions of Synthwave '84 relied on a third-party extension to inject an extra stylesheet into the VS code editor. This was a hacky, if serviceable, method for adding extra styles, but I suspected it would never be long-term. With the v1.40.0 release of VS code, those suspicions were confirmed - the internal classes used for the glow had been changed. It was clear that another solution would be needed. This new update, called Neon Dreams, removes the need to use the Custom CSS and JS extension to enable the glow. It also rethinks how to apply the glow, so it should cope better with updates to VS code. ## Before using If you have been using Synthwave prior to v0.1.0, you will likely have used the Custom CSS and JS extension to enable the glow. Before enabling Neon Dreams, you will need to disable the prior method. You can do this by pressing Ctrl + Shift + P or Shift + ⌘ + P and choose "Disable Custom CSS and JS". In the name of simplifying the install process and mitigating update-related issues, the new version of Synthwave doesn't use a custom CSS file anymore. If you have been using a custom-modified version of the theme, then you might want to leave it as is for now. ## Start dreaming Firstly, if you are a Windows user, you may need to run VS Code with administrator privileges. For Linux and Mac users, Code must not be installed in a read-only location and you must have write permissions. Once you have admin permissions, open VS Code, and set your active colour theme to Synthwave '84 - the glow is only active when the base theme is selected. press Ctrl + Shift + P or Shift + ⌘ + P and choose "Enable Neon Dreams" You should be prompted for a restart. When the editor reloads, the glow should be active. As with the prior versions, VS Code may warn you that your installation is corrupt. You can suppress this message by choosing 'Don't show this again' from the notification. Also as before, whenever VS code updates, you will need to rerun the enable command
Robb Owen DigitalRobb Owen Digital
LANGUAGES AND LINGUISTICS

How to name your agency with NLP

As with so many things these days, it starts with clickbait. "17 tricks to running a digital agency" was, for the most part, the same kind of unremarkable listicle that surfaces on Twitter from time to time. You know the sort - it keeps things light, you chuckle at a few zingers about office-dogs, exposed piping and wall-mounted fixies. You hit retweet and carry on with the day. This time though, there was one section that stood out to me. On naming your agency, it read: Just pick a random word or two, and go with that Could it really be that simple? Let's find out. ## Striking the right tone Random is a pretty strong word. Think about the web agencies that you admire. The best names might seem random but usually carry some extra weight or depth behind them. This is all perhaps quite obvious, but it pokes an immediate flaw in our meme-ified advice. How can our choice be random, but still retain some deeper meaning? Fortunately for our soon-to-be-industry-leading web agency, there's a whole branch of machine learning that can help us automate exactly this kind of problem: Natural Language Processing. ## An introduction to NLP Natural Language Processing, or NLP, is all about training computers to process and understand human languages. At this point you might be thinking that teaching language to humans is already pretty difficult and, honestly, you wouldn't be wrong. NLP is a really dense topic, but building even the smallest NLP applications can feel hugely empowering. That said, NLP tutorials have a tendency to get very technical, very quickly and they often assume that anyone entering the field is coming armed with a dual-PhD in linguistics and computer science. On top of that, NLP and machine learning are often cushioned by a thick layer of marketing BS that makes it hard to separate reality from building SkyNet. It's clear that NLP is an exciting topic but, for newcomers, all of the above means that it isn't always clear what you might actually want to do with it. In this tutorial, we're going to start slow and ease into a few core NLP concepts. We'll write a short script to 'borrow' the tone of voice from other agencies that we like. We'll do this by scraping their websites and plucking a list of nouns and adjectives from the text content. Finally, as the original meme suggested, we'll randomly mash some of those words together to generate a few names for our awesome new web agency. ## Pre-requisites and setup Python is considered to be the de facto place to start for natural language processing and to generate our agency name, we're going to need Python 3.7. We'll also need to manage our dependencies with a Pipfile (for the JavaScript fluent amongst you: think package.json). For that, we're also going to need pipenv. If you're new to Python, then you can install everything you need with the brilliant Hitchhiker's guide. Once you have installed Python itself, the guide has a handy page explaining how to install pipenv. This tutorial will assume some knowledge of Python syntax, but I'll keep the code as simple as possible. With everything installed, create a project directory and we're ready to get going. ## The Corpus Before we can process any text content, we first need to find a suitable a body of text. In NLP terminology this input text is known as our 'corpus'. In our case, we want to grab our text content from other web agencies that we admire, so we can kick things off by writing a function to grab text content from a remote url. Fortunately the Beautiful Soup package helps us to do this. Let's install Beautiful Soup, and the HTML5 library to our project dependencies. Open the project directory in your terminal and run: c pipenv install bs4 html5lib Next, create a file called main.py with the following contents: python import requests # we'll need this to make requests to URLs from bs4 import BeautifulSoup, Comment # HTML and comment parsers def read_urls(urls): content_string = '' for page in urls: print(f'Scraping URL: {page}') # Synchronous request to page, and store returned markup scrape = requests.get(page).content # Parse the returned markup with BeautifulSoup's html5 parser soup = BeautifulSoup(scrape, "html5lib") # loop script tags and remove them for script_tag in soup.find_all('script'): script_tag.extract() # loop style tags and remove them for style_tag in soup.find_all('style'): style_tag.extract() # find all comments and remove them for comment in soup(text=lambda text: isinstance(text, Comment)): comment.extract() # Join all the scraped text content text = ''.join(soup.findAll(text=True)) # Append to previously scraped text content_string += text return content_string You don't need to worry too much about the specific code of the function above, but it goes as follows: It read a list of URLs, making a request to each before parsing the content with BeautifulSoup. The scraped markup needs to be tidied up a bit though, so next it removes any comments, script or style tags before pulling out the remaining text content and appending it to a string. When every URL in the list has been scraped, the string is returned as our corpus. Now we have our corpus, we can start to process it. ## Lost in SpaCy SpaCy bills itself as 'Industrial-strength Natural Language Processing in Python'. Whereas packages like NLTK offer immense amounts of flexibility and configuration, the flipside is a steep learning curve. SpaCy on the other hand, can be set up and ready to go in minutes. We need to add SpaCy to our project as a dependency. Open your project directory in your terminal and run: c pipenv install spacy SpaCy doesn't do much by itself though - it needs a pre-trained language model to work with. Fortunately several are available out of the box. We'll also install the standard english model and save it to our Pipfile: c pipenv install https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-2.2.5/en_core_web_sm-2.2.5.tar.gz With our dependencies installed, let's get back into the code. Open up main.py and add SpaCy to our imports at the top: python import requests # we'll need this to make requests to URLs from bs4 import BeautifulSoup, Comment # HTML and comment parsers import en_core_web_sm # SpaCy's english model Next, underneath our read_urls function, we can define a new function to allow SpaCy to parse the corpus. python def get_tokens(corpus): # load the pre-trained english model nlp = en_core_web_sm.load() # parse the corpus and return the processed data return nlp(corpus) Notice the name of our function, get_tokens. What's that all about? ## Parts of Speech and Tokenization Most languages divide their words up into different categories. These usually comprise nouns, verbs, adjectives, adverbs and more. In linguistic terms, these categories are known as 'parts of speech', or POS. This is important to us as, to generate our agency name, we want to single out nouns and adjectives. But how can we get at them? One place to start would be to break each sentence up into its individual parts but this isn't a straightforward as using String split(). We need a way to intelligently divide our text, whilst retaining the individual purpose of each word. Doing this manually would be too time consuming, so this is where machine learning becomes necessary. Most NLP libraries use trained language data to intelligently scan, compare and identify the characteristics of each word. Once identified, these words are separated out into 'tokens'. This process is known as tokenization and is one of the most important core principles of NLP. Our get_tokens function tokenizes our corpus. Or, in simpler terms, SpaCy reads over the corpus, splits out individual words and returns a list of tokens. In creating that list, SpaCy also adds a lot of extra information to each extracted word, such as its relationship to other words in the sentence or which part of speech it is. We can use those attributes to filter the list down to find a specific part of speech. Let's create another function: python def get_word_list(tokens, part_of_speech): # extract a list of words and capitalise them list_of_words = [ word.text.capitalize() for word in tokens if word.pos_ == part_of_speech ] # make the list unique by casting it to a set unique_list = set(list_of_words) # cast it back to a list and return return list(unique_list) This function receives the list of tokens, a part of speech code, and then filters the results using a list comprehension (again, for JS developers, a list comprehension is a bit like a cross between Array.map and Array.filter). To break this list comprehension down, it's best to work from the inside out – For every word in our tokens list, we grab word.text and capitalise it, but only if the part of speech (word.pos_) matches our POS code parameter. You can find a full list of these POS codes in the SpaCy POS documentation. If you're curious about what other attributes might be available on each token, you can read the Token reference. Lastly, we can filter out any duplicated words by casting the results to a set(). We'll need to perform some list actions later though, so it is cast back to a list before being returned. This is all looking great and we can now use this function to create a list of nouns, adjectives, verbs or adverbs. There's a slight problem though - if you were to use this function as-is, you might find that the list of words you get back is a bit of a mess. ## Stop words Most languages have a set of core words that occur frequently. In English, this might be words like "the", "at", "from", "in", "for" and so on. Whilst these words are useful for humans, they can quite quickly get in the way extracting meaning with NLP. As a result, it's common to filter them out and these kinds of words are known as stop words. Our script is no different and the stop words need to go. Whilst we're here, we can also get rid of any rogue punctuation. Fortunately SpaCy tokens have two handy attributes that allow us to do this - the aptly named is_stop and is_punct. Let's update the list comprehension in our get_word_data function: python list_of_words = [ word.text.capitalize() for word in tokens if word.pos_ == part_of_speech and not word.is_stop and not word.is_punct ] Now we can extract a nice sanitised list of words, but there's a tiny bit more optimisation that we can do. ## Lemmatization Let's say you've scraped your website content and in amongst your list of tokens are the words "goes", "going", "went" and "gone". That's quite a few words to express the same base idea. We can make this simpler. These words could all be lumped together under the core concept: "go". In linguistic terms, we call this a word's 'lemma' or the uninflected root form of a word. The simplest way to think of a lemma is to see it as what you might look for in a dictionary: Out of the box, SpaCy provides a convenient means to convert any matched term back to its lemma. This is called lemmatization. Let's hone our list of tokens down into a much tighter list of core ideas by updating our list comprehension. We'll change the first line to call .lemma_ instead of .text. Your final get_word_list function should look like this: python def get_word_list(tokens, part_of_speech): # extract a list of lemmas and capitalise them list_of_words = [ word.lemma_.capitalize() # <-- update .text to .lemma_ for word in tokens if word.pos_ == part_of_speech and not word.is_stop and not word.is_punct ] # make the list unique by casting it to a set unique_list = set(list_of_words) # cast it back to a list and return return list(unique_list) ## Bringing it all together Now we've set up a few functions and we've covered the core concepts, let's pull everything together. To generate the final name, we're going to need to be able to make random selections from our word lists. At the top of main.py, add random to your imports. python import requests # we'll need this to make requests to URLs from bs4 import BeautifulSoup, Comment # HTML and comment parsers import en_core_web_sm # SpaCy's english model import random At the end of the file, underneath your get_word_list function, paste the following: python # create a list of urls to scrape # For illustration, I'm using my website # but replace with any urls of your choice CORPUS = read_urls([ 'https://robbowen.digital/', 'https://robbowen.digital/work', 'https://robbowen.digital/about' ]) # parse the corpus and tokenize TOKENS = get_tokens(CORPUS) # extract a unique list of noun lemmas from the token list nouns = get_word_list(TOKENS, "NOUN") # extract a unique list of adjective lemmas from the token list adjectives = get_word_list(TOKENS, "ADJ") # create a random list of 500 nouns and adjective-noun combos # cast to a set to make sure that we only get unique results list_of_combos = set( [ # String interpolation of an adjective and a noun... f'{random.choice(adjectives)} {random.choice(nouns)}' # 50% of the time... if random.choice([True, False]) is True # else return a random noun else random.choice(nouns) # and repeat this 500 times for x in range(500) ] ) # Write list of combinations to a text file output_file = open("./output.txt", 'w') for item in list_of_combos: output_file.write(f'{item}\r\n') Let's break that code down. First of all, we pass a list of URLs into our read_urls function and store the result in a constant for later. Be sure to change the example URLs to your own choices here, and remember: the more text on your chosen pages, the better your results will be. Next, we pass the corpus into our get_tokens function. This is where SpaCy does its magic, tokenizing the corpus and returning a list of data-rich tokens. To generate our agency name, we want some random nouns. Nouns by themselves aren't much fun, so we'll grab some adjectives too. This uses the get_word_list function we created earlier. The first argument is the list of tokens, and the second filters by a 'part of speech code'. Now we have our nouns and adjectives lists we can prep our final list of names. We want to generate a list of names – let's say 500 of them. We can use another list comprehension to do this. Remember the random library we imported above? It gives us access to the random.choice function. As the name implies, it allows us to randomly choose a single item from a list. random.choice also gives us a handy way to embed a 50/50 chance into our comprehension. Here, an if-else means each iteration of our loop will return either an adjective-noun combo or a single noun. Finally, with the comprehension complete, we wrap the list in set() to ensure that we only have unique results. Ok, everything is ready. At the very end we create a file called output.txt, loop through our list of names and write each to a new line. And...we're done! If you need to, you can double check your code against the final source on github. ## Generate the name It's time to fire up the final script and generate some names. At your terminal, run the following command: c pipenv run python main.py If all goes well, you should have now a file named output.txt chock full of multi-award-winning agency names. ## And the winner is... "Just pick a random word or two and go with that". So, after all this, what name did I go with? Well, I had some real gems: Loaf, Strategic Bear, Rise, Chaotic Rest, Bold Tomorrow. After a few minutes crawling through the output list though, I spotted the one. Exciting, yet confident. Like a life-raft in a sea of comedic nonsense: ## Wrapping up Ok, I get it. Like most clickbait, this might not have been a completely satisfying ending. Hopefully though, you've learned a little about the potential application of even the most superficial Natural Language Processing scripts. To recap, in this introduction, we've used and learned about: - Corpus: The body of text to be processed - Parts of speech: The name given to the categories of words comprising nouns, verbs, adjectives etc. - Tokenization: The act of splitting sentences into individual words, or tokens - Stop words: Small, frequently occurring words that we do not wish to include in our results i.e. "the", "for", "it" etc. - Lemmatization: Changing a word back to its root concept, or dictionary form i.e. the lemma of "best" or "better" is "good" From these core concepts, the possibilities are vast. Imagine going into a pitch having scanned the client's website in advance to profile exactly how they talk about themselves. Or perhaps you could take the jump into sentiment analysis to scan how well received a particular topic is on Twitter. There's still a lot to learn but, with these core building blocks, you're on your way. If you enjoyed this article and have any ideas on how to extend this example, or if you generated a particularly badass name, then please let me know on Mastodon. If you're looking to learn more about NLP, I strongly recommend the Advanced NLP with SpaCy course by Ines Montani - it's free, and really rather good.
Robb Owen DigitalRobb Owen Digital
HUMAN RESOURCES AND WORKFORCE PLANNING

The year that was(n't)

Holy. Shit. Let's get it out of the way - 2020 has been a prize dickhead. Like many of you I started the year with plans. I was going to learn X, Y and Z. I was going be more active with the industry on Twitter. I was going to work on my side-project and, after taking nowhere near enough holiday in 2019, I was going to travel more. Sufficed to say, that is not the year I had. I was fortunate to take a (very-surreal) trip to Japan at the start of the year, but I flew home with just enough time to get myself into lockdown. I haven't really been outside of Abergavenny since. Then, there was this website. I was super-excited to launch it back in January, and the positive feedback absolutely blew me away. As I wrote back then, one of my big motivators in building the site was my intention of properly blogging for the first time. Well, a quick look at my articles page will show that it didn't really pan out. Some of you might also have noticed that I have been a lot quieter on Twitter lately too. It needs to be said though, that all of the above is utterly inconsequential in the face of what we have collectively faced in a year that has been so painful for so many people. 2020 has felt a lot like treading water. Like many of you I'm sure, maintaining a business, my physical and mental health, and the health of those around me with everything that's going on has been utterly exhausting. Honestly, making it through the year is enough, and something that we can all be proud of. That said, in the spirit of reviving this blog I feel that writing a little more introspectively about my year might help me to make sense of the chaos. ## Work Working as an independent developer, maintaining a consistent work-stream is always playing on your mind. After a very busy December in 2019, I had a quiet start to the year before eventually hitting my stride during lockdown. From March onwards I was very fortunate to have a steady stream of client work right up until about two weeks ago. With hindsight though, I overdid it. I believe in doing things well and as a result, put a lot of myself into my work. The year’s continuous and over-arching sense of oh shit, what now!? meant that ultimately I took on a massive amount but didn’t allow myself enough time between projects to decompress. The run up to Christmas is the closest that I've come to burn-out since I went freelance, and I'm definitely going to slow down for a while as a result. On the positive side though, this year I’ve done some of the most technically complicated work of my career. Whilst I mostly can’t share it publicly due to NDA (that old cliché!), I've had a string of happy clients and I’m very proud of what I’ve achieved given the circumstances. Another unexpected silver-lining is that the pandemic has normalised remote work. Living in rural Wales, I usually work remotely to avoid a long commute. The sudden change has created new opportunities and it will be interesting to see how this shift pans out longer term, once it is safe for my clients to return to their offices. ## Personal life As a response to my overworking and having extra time at home due to the lockdown, I've thankfully been able to prioritise more offline activities this year. Every Autumn, like clockwork, I start to miss being a student — no, not the heavy drinking — rather, the feeling of purposeful learning. This year was no different, except that I decided to do something about it. Around 2011, I studied Korean on-and-off for about a year. Since then I’ve mostly forgotten everything, beyond being able to read Hangul. In late October I bought a Korean grammar textbook and started regularly studying again. So far I'm absolutely loving it. Whilst the language obviously has its own unique history and culture, on a purely linguistic-level Korean syntax and vocabulary are not a million miles removed from what I already know from having studied Japanese at university. It's empowering to feel like I'm building on top a shared-foundation, rather than learning something totally new. Whenever it's safe to travel again, I can't wait to visit South Korea. Hopefully I can get to a conversational level by then. Lastly, the one thing in 2020 that has made the biggest positive impact is that, with the pandemic, my partner's job has become remote and she has been able to move in with me. Due to our working circumstances, my partner and I have been living apart on opposite sides of the country - it has been beyond amazing to finally spend out first Christmas together. Oh, we also got a kitten, Frīda, who is a constant source of joy. It's been a long while since I had a pet and I can't overstate the positive and motivating effect of having a little ball of chaotic energy in our home. ## Looking forward Well, we made it through a very difficult year. Unsurprisingly, I'm not going to offer any predictions or goals for the new year. Doing our best day-by-day seems more attainable for now. If you’ve hired me to help out with your project, used any of my open source work, read my blog, or promoted my work in any way, I’d like to say thanks – you’re awesome. Stay safe, everyone. Happy new year!
KOREA
Robb Owen DigitalRobb Owen Digital
ANIMATION AND VISUAL EFFECTS

Holograms, light-leaks and how to build CSS-only shaders

I might be understating it a bit, but WebGL is a big deal. You only need to spend five minutes on one of the many design awards sites to see site-after-site fully leaning into the power of canvas. Tools like threejs make it easier to harness the power of 3D and GLSL (OpenGL Shading Language) shaders and, with that, a whole new level of visual effects. This got me thinking though; why let JS have all the fun? With mix-blend-mode finally gaining wide browser-support, we now have access to many of most common shading techniques in CSS. With some choice images and a bit of careful layering it's possible to build some surprisingly high-quality effects without the need for introducing any JS dependencies. Let's take a look at an example. As you scroll past the image below, the sunlight blooms a warm orange, before fading to a cool blue. You'll also briefly see some lens bokeh. Ooooh shiny. Let's break it down. ## What is a CSS 'shader'? Shaders in the WebGL world are complex GLSL scripts that determine how each individual pixel is rendered to the screen. We still don't have that level of control in our CSS so, at its most basic level, our CSS 'shader' is just an image with additional background-image layers above it. Yes, I'm taking a few liberties with the name but with careful use of gradients, masking, nesting and mix-blend-mode we can manage how these layers interact with both one-another and the image at the bottom of the stack. For the sake of visualising this core structure, the 'shader' example above is set up with a few nested divs: html <div class="shader"> <img src="tower.jpg" alt="Asakusa at dusk"> <div class="shader__layer specular"> <div class="shader__layer mask"></div> </div> </div> To keep each layer aligned with the image at the base, we keep the nested content positioned with the following CSS: css .shader { position: relative; overflow: hidden; backface-visibility: hidden; /* to force GPU performance. More on that later */ } .shader__layer { background: black; position: absolute; left: 0; top: 0; width: 100%; height: 100%; background-size: 100%; } Ok, with the basic layout taken care of, lets take a look at the first layer of our effect - the lighting. ## Simulating specularity First of all we need to think about how light travels from light to dark across the surface of our image. We'll need an area of brightness where the light is at its most intense, falling off gradually into darkness as the light dissipates. We'll do this with a gradient. css .specular { background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black); } If you imagine looking at a shiny surface, the light that reflects back is known as a specular reflection. How and where that highlight appears is dependant on the light source, but also your viewing angle - the highlight moves with you. Whilst our gradient is looking lovely, it's all a bit static. We'll need to introduce a bit of movement to really sell the effect. Fortunately, there's a vintage CSS Level 1 property that can help with that; setting .specular to background-attachment: fixed means that, as the page scrolls, the gradient remains locked to the browser's viewport. This not only brings some much needed motion to our shader, but also means that we can very roughly simulate the changing view-angle without reaching for JavaScript. css .specular { background-attachment: fixed; background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black); } Great! Now, let's get to applying this lighting to our base image. ## Know your blend modes As the name implies, mix-blend-mode mixes the colours of each pixel in one element layer together with the layer directly below it. As with GLSL, CSS gives us a nice long list of options to choose from, and creating the right effect means knowing which blend is going to give the results we need. But what do these blend modes actually do? Before we get stuck in with our shader, let's take a quick look at the blend modes we'll be using. Below you can see the images that we'll be using for these examples. On the left is the upper layer to be blended, and the right is the base image we'll be blending onto. First off, let's look at a multiply blend. Multiply takes the colour of each pixel in the current layer and multiplies it with the colour of the pixel directly beneath it. In practice this means that darker colours in the current layer will obscure those in the layer below: Setting the blend to screen takes the inverse of each pixel and multiplies them before inverting the result. This might sound complicated, but you can think of screen as the opposite of multiply - Darker colours become transparent and only the lighter colours will show through to the layer below: Lastly, color-dodge and color-burn are like taking multiply and screen into overdrive. Both modes divide the pixel colour on the base layer with the one in the current layer. For color-dodge this means that highlights and midtones get blown out whilst dark tones have no affect at all. color-burn will boost shadows and darker midtones, whilst lighter tones have no affect at all. In the image below, the left example is color-dodge and the right is color-burn. ## Compositing layers Now we know what we're working with, it might seem like the next step would be to slap a blend mode onto our gradient, place it over our base image and call it a day. That will definitely work, but it won't reach the level of quality we're going for. Blending the gradient directly to the base layer will mean that the lighting will be totally uniform across the image. Aside from chromed surfaces, that doesn't happen too often in nature. To really sell the effect we want to have control over the areas of the image where light can fall and where it can't. To simulate this, we can use a predominately dark image to mask out our gradient. This technique of using a dark image to mask off areas of a lighter one is often known as a specular map. You might be wondering how it will be possible to do this with CSS if mix-blend-mode only affects the pixels in the layer directly below it, and we can only set one blend mode at a time. This is where our HTML structure starts to shine. html <div class="shader"> <img src="tower.jpg" alt="Asakusa at dusk"> <div class="shader__layer specular"> <div class="shader__layer mask"></div> </div> </div> By nesting div layers inside of one another, we can work outwards applying additional mix-blend-modes to each wrapping div. Essentially, this lets us add another mix-blend-mode to the output of the previous blend. This process of layering different blend modes to produce a final output is known as compositing. Let's give it a try. Using a suitably dark background-image we'll set mix-blend-mode: multiply on our .mask layer and throw away the parts of our gradient where we don't want light to show through. css .mask { mix-blend-mode: multiply; background-image: url(/tower_spec.jpg); } .specular { background-attachment: fixed; background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black); } Now that we have our specular map, we can apply the final lighting to the base image. We need to use one of the blend modes that ignores black and dark tones. This means that we should set our .specular layer to use mix-blend-mode: screen or mix-blend-mode: color-dodge. Either would work in this case, but because we want the highlights to blow out into a nice sunlight-bloom effect, we'll go for color-dodge. Let's take a look: css .specular { mix-blend-mode: color-dodge; background-attachment: fixed; background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black); } ## The final shader And with that, the effect is complete! Here's the completed HTML and CSS: html <div class="shader"> <img src="tower.jpg" alt="Asakusa at dusk"> <div class="shader__layer specular"> <div class="shader__layer mask"></div> </div> </div> <style> .shader { position: relative; overflow: hidden; backface-visibility: hidden; /* to force GPU performance */ } .shader__layer { background: black; position: absolute; left: 0; top: 0; width: 100%; height: 100%; background-size: 100%; background-position: center; } .specular { mix-blend-mode: color-dodge; background-attachment: fixed; background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black); } .mask { mix-blend-mode: multiply; background-image: url(/tower_spec.jpg); } </style> Let's take another look at the completed effect, but this time with the added ability to isolate each layer of the shader. Change the view mode dropdown to step through the effect and get a better idea of how the layers work together to produce the final image: View mode: No effect Specular only Specular + base image Mask only Specular + mask * Full effect ## Taking it further In the example above, we used a greyscale version of the main image (with added scratches and bokeh) as a mask. That's a great way to add interest to an image, but shader layers can be whatever you want them to be. Let's look at a few more examples. ### Aurora Borealis In this example, repeating the gradient background gradient and reducing background-size-y causes the light effect to move more quickly across the screen. When masked out with a specular map, this creates the illusion of an aurora rippling over the main image. Blown-out highlights with colour-dodge creates the wrong effect here, so swapping our .specular layer to mix-blend-mode: screen maintains the sharp definition of the aurora. View mode: No effect Specular only Specular + base image Mask only Specular + mask * Full effect ### Light-leak Until now the examples have all used a greyscale specular map, but a full colour specular map can introduce new effects. In this example the mask image is created from an inverted and blurred version of the primary image, with a blue-red tone overlaid on top. When it's all layered together with a hot red-orange gradient, the resulting blend of colours results in something that looks a little like the light-leaks you get on vintage film cameras. View mode: No effect Specular only Specular + base image Mask only Specular + mask * Full effect ### Hologram Layering within the mask opens up even more possibilities. What would happen if we add another layer with background-attachment: fixed? In this final example the mask layer has a background image SVG, and another black-white gradient running at the opposite angle to the specular gradient in the .specular layer. Setting the nested mask layer to color-burn causes it to blow out the definition of the SVG and you get this sweet two-way holographic look. CSS is awesome. View mode: No effect Specular only Specular + base image Mask only Specular + mask * Full effect ## Wrapping up I've said it before, but it bears repeating: modern CSS is such an amazing tool to work with and I'm consistently impressed with the level of fidelity you can achieve. That said, It's time to address the potential elephant in the room. Depending on the device or browser you've used to read this article, your scroll performance might be on the floor and your fans may have kicked up a notch. At the time of writing, blend modes in browsers are still pretty resource heavy. For more complex effects, with several layers of compositing, you will see a real performance hit. Add any CSS animations or transitions to the mix and it will really tank - particularly in Safari. After a bit of tweaking I eventually managed to claw back a bit of a performance boost by using backface-visibility: hidden, but my initial impulse was to force GPU rendering by using the trusty transform: translateZ(0); hack. Sadly, adding a transform revealed another quirk to be aware of. Due to the reliance on background-attachment: fixed, applying CSS transforms to the shader can cause some strange side-effects. In Chrome it broadly works, but the gradients may appear offset depending on the transforms applied. Firefox on the other hand, will simply ignore the fixed positioning and your gradients will totally appear static. I'm sure there are ways and means around that, but they'd likely be testing the spirit of doing all of this without using JS. All-in-all this was really fun to explore. Sure, we can't get quite the same level of fidelity as GLSL, but for simpler effects this technique is a great alternative to introducing extra libraries to your project. As great as these effects can look though, I think that for now this is very much a case of: just because you can, doesn't mean you should. Until CSS filters and blend modes become more performant (or until browsers allow linking GLSL filters directly from CSS 🤞) restraint and subtlety might be the best way to go. ## Image credits The demos in this article use a collection of images or composited textures made available by the Unsplash photographers below. Please go check out their beautiful work. - Bokeh lights by Hello I'm Nik - Silhouette of pine trees under stars by Joshua Woroniecki - Aurora by Johannes Groll - Lake under blue sky by Juan Davila - Vintage dashboard by Wassim Chouak - Light leak by Sean Sinclair - Scratches by Dan Cristian Pădureț
JUAN DAVILASEAN SINCLAIR