Developer News
Bringing Back Parallax With Scroll-Driven CSS Animations
For a period in the 2010s, parallax was a guaranteed way to make your website “cool”. Indeed, Chris Coyier was writing about it as far back as 2008.
For those unfamiliar with the concept, parallax is a pattern in which different elements of a webpage move at varying speeds as the user scrolls, creating a three-dimensional, layered appearance. A true parallax effect was once only achievable using JavaScript. However, scroll-driven animations have now given us a CSS-only solution, which is free from the main-thread blocking that can plague JavaScript animations.
Parallax may have become a little cliché, but I think it’s worth revisiting with this new CSS feature.
Note: Scroll-driven animations are available on Chrome, Edge, Opera, and Firefox (behind a feature flag) at the time of writing. Use a supported browser when following this tutorial.
Starting codeIn this example, we will apply parallax animations to the background and icons within the three “hero” sections of a universe-themed webpage. We’ll start with some lightly styled markup featuring alternating hero and text sections while including some space-related nonsense as placeholder content.
CodePen Embed Fallback Adding initial animationsLet’s add an animation to the background pattern within each hero section to modify the background position.
@keyframes parallax { from { background-position: bottom 0px center; } to { background-position: bottom -400px center; } } section.hero { /* previous code */ + animation: parallax 3s linear; }Here we use the keyframes CSS rule to create a start and end position for the background. Then we attach this animation to each of our hero sections using the animation property.
By default, CSS animations are duration-based and run when the specified selector is loaded in the DOM. If you refresh your browser, you will see the animation running for three seconds as soon as the page loads.
We do not want our animation to be triggered immediately. Instead, we intend to use the page’s scroll position as a reference to calculate the animation’s progress.
Scroll-driven animations provide two new animation timeline CSS functions. These additions, view() and scroll(), tell the browser what to reference when calculating the progress of a CSS animation. We will use the view() function later, but for now, let’s focus on scroll(). The scroll progress timeline couples the progression of an animation to the user’s scroll position within a scroll container. Parameters can be included to change the scroll axis and container element, but these are not necessary for our implementation.
Let’s use a scroll progress timeline for our animation:
section.hero { /* previous code */ - animation: parallax 3s linear; + animation: parallax linear; + animation-timeline: scroll(); }If you refresh the page, you will notice that as you scroll down, the position of the background of each hero section also changes. If you scroll back up, the animation reverses. As a bonus, this CSS animation is handled off the main thread and thus is not subject to blocking by any JavaScript that may be running.
Using the view progress timelineNow let’s add a new parallax layer by animating the header text and icons within each hero section. This way, the background patterns, headers, and main page content will all appear to scroll at different speeds. We will initially use the scroll() CSS function for the animation timeline here as well.
@keyframes float { from { top: 25%; } to { top: 50%; } } .hero-content { /* previous code */ + position: absolute; + top: 25%; + animation: float linear; + animation-timeline: scroll(); }That’s not quite right. The animation for the sections further down the page is nearly done by the time they come into view. Luckily, the view animation timeline solves this problem. By setting the animation-timeline property to view(), our animation progresses based on the position of the subject within the scrollport, which is the part of the container that is visible when scrolling. Like the scroll animation timeline, scrolling in reverse will also reverse the animation.
Let’s try changing our animation timeline property for the hero text:
.hero-content { /* previous code */ - animation-timeline: scroll(); + animation-timeline: view(); }That looks pretty good, but there is a problem with the header content flashing into the view when scrolling back up the document. This is because the view timeline is calculated based on the original, pre-animation positioning of the subject element.
We can solve this by adding an inset parameter to the view() function. This adjusts the size of the container in which the animation will take place. According to MDN’s documentation, the “inset is used to determine whether the element is in view which determines the length of the animation timeline. In other words, the animation lasts as long as the element is in the inset-adjusted view.”
So, by using a negative value, we make the container larger than the window and trigger the animation to start a little before and end a little after the subject is visible. This accounts for the fact that the subject moves during the animation.
- animation-timeline: view(); + animation-timeline: view(-100px);Now both the text and background animate smoothly at different speeds.
CodePen Embed Fallback Adjusting animations using animation rangesSo far, we have employed both scroll and view progress timelines. Let’s look at another way to adjust the start and end timing of the animations using the animation-range property. It can be used to modify where along the timeline the animation will start and end.
We’ll start by adding a view() timeline animation to the #spaceship emoji:
@keyframes launch { from { transform: translate(-100px, 200px); } to { transform: translate(100px, -100px); } } #spaceship { animation: launch; animation-timeline: view(); }Again, we see the emoji returning to its 0% position once its original unanimated position is outside of the scrollport.
As discussed before, animations are based on the original pre-animation position of the subject. Previously, we solved this by adding an inset parameter to the view() function. We can also adjust the animation range and tell our animation to continue beyond 100% of the animation timeline without having to manipulate the inset of the view timeline itself.
#spaceship { animation: launch; animation-timeline: view(); + animation-range: 0% 120%; }Now the animation continues until we have scrolled an extra 20% beyond the calculated scroll timeline’s normal endpoint.
Let’s say that we want to add an animation to the #comet emoji, but we don’t want it to start animating until it has passed 4rem from the bottom of the scrollport:
@keyframes rotate { from { transform: rotate(0deg) translateX(100px); } to { transform: rotate(-70deg) translateX(0px); } } #comet { animation: rotate linear; transform-origin: center 125px; animation-timeline: view(); animation-range: 4rem 120%; }Here we see the “delayed” animation in action:
We can also combine animation ranges to run completely different animations at different points within the same timeline! Let’s illustrate this by combining animation ranges for the #satellite icon at the top of the page. The result is that the first animation runs until the icon passes 80% of the scrollport, then the second animation takes over for the final 20%.
@keyframes orbit-in { 0% { transform: rotate(200deg); } 100% { transform: rotate(0deg); } } @keyframes orbit-out { 0% { transform: translate(0px, 0px); } 100% { transform: translate(-50px, -15px); } } #satellite { animation: orbit-in linear, orbit-out ease; animation-timeline: view(); animation-range: 0% 80%, 80% 110%; } Fallbacks and accessibilityOur webpage features numerous moving elements that may cause discomfort for some users. Let’s consider accessibility for motion sensitivities and incorporate the prefers-reduced-motion CSS media feature.
There are two possible values: no-preference, and reduce. If we want to fine-tune the webpage with animations disabled by default and then enhance each selector with animations and associated styles, then we can use no-preference to enable them.
@media (prefers-reduced-motion: no-preference) { .my-selector { position: relative; top: 25%; animation: cool-animation linear; animation-timeline: scroll(); } }For us, however, the webpage content and images will still all be visible if we disable all animations simultaneously. This can be done concisely using the reduce option. It’s important to note that this sort of blanket approach works for our situation, but you should always consider the impact on your specific users when implementing accessibility features.
@media (prefers-reduced-motion: reduce) { .my-selector { animation: none !important; } }In addition to considering accessibility, we should also take into account that scroll-driven animations are not supported by all browsers at the time of writing. If we care a lot about users seeing our animations, we can add a polyfill (direct link) to extend this functionality to currently unsupported browsers. This, however, will force the animation to run on the main thread.
Alternatively, we could decide that performance is important enough to skip the animations on unsupported browsers, thereby keeping the main thread clear. In this case, we can use the @supports selector and include the styles only on supported browsers.
Here is the final code with everything, including the polyfill and reduced motion fallback:
CodePen Embed Fallback ConclusionThere we go, we just re-created a classic web effect with scroll-driven animations using scroll and view progress timelines. We also discussed some of the parameters that can be used to adjust animation behavior. Whether or not parallax is your thing, I like the idea that we can use a modern approach that is capable of doing what we could before… only better with a dash of progressive enhancement.
More information- Unleash the Power of Scroll-Driven Animations
- animation-timeline (CSS-Tricks)
- CSS scroll-driven animations (MDN)
- Scroll-driven Animations Demo Site (Bramus)
Bringing Back Parallax With Scroll-Driven CSS Animations originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Keeping Article Demos Alive When Third-Party APIs Die
After four years, the demos in my “Headless Form Submission with the WordPress REST API” article finally stopped working.
The article includes CodePen embeds that demonstrate how to use the REST API endpoints of popular WordPress form plugins to capture and display validation errors and submission feedback when building a completely custom front-end. The pens relied on a WordPress site I had running in the background. But during a forced infrastructure migration, the site failed to transfer properly, and, even worse, I lost access to my account.
Sure, I could have contacted support or restored a backup elsewhere. But the situation made me wonder: what if this had not been WordPress? What if it were a third-party service I couldn’t self-host or fix? Is there a way to build demos that do not break when the services they rely on fail? How can we ensure educational demos stay available for as long as possible?
Or is this just inevitable? Are demos, like everything else on the web, doomed to break eventually?
Parallels with software testingThose who write tests for their code have long wrestled with similar questions, though framed differently. At the core, the issue is the same. Dependencies, especially third-party ones, become hurdles because they are outside the bounds of control.
Not surprisingly, the most reliable way to eliminate issues stemming from external dependencies is to remove the external service entirely from the equation, effectively decoupling from it. Of course, how this is done, and whether it’s always possible, depends on the context.
As it happens, techniques for handling dependencies can be just as useful when it comes to making demos more resilient.
To keep things concrete, I’ll be using the mentioned CodePen demos as an example. But the same approach works just as well in many other contexts.
Decoupling REST API dependenciesWhile there are many strategies and tricks, the two most common approaches to breaking reliance on a REST API are:
- Mocking the HTTP calls in code and, instead of performing real network requests, returning stubbed responses
- Using a mock API server as a stand-in for the real service and serving predefined responses in a similar manner
Both have trade-offs, but let’s look at those later.
Mocking a response with an interceptorModern testing frameworks, whether for unit or end-to-end testing, such as Jest or Playwright, offer built-in mocking capabilities.
However, we don’t necessarily need these, and we can’t use them in the pens anyway. Instead, we can monkey patch the Fetch API to intercept requests and return mock responses. With monkey patching, when changing the original source code isn’t feasible, we can introduce new behavior by overwriting existing functions.
Implementing it looks like this:
const fetchWPFormsRestApiInterceptor = (fetch) => async ( resource, options = {} ) => { // To make sure we are dealing with the data we expect if (typeof resource !== "string" || !(options.body instanceof FormData)) { return fetch(resource, options); } if (resource.match(/wp-json\/contact-form-7/)) { return contactForm7Response(options.body); } if (resource.match(/wp-json\/gf/)) { return gravityFormsResponse(options.body); } return fetch(resource, options); }; window.fetch = fetchWPFormsRestApiInterceptor(window.fetch);We override the default fetch with our own version that adds custom logic for specific conditions, and otherwise lets requests pass through unchanged.
The replacement function, fetchWPFormsRestApiInterceptor, acts like an interceptor. An interceptor is simply a pattern that modifies requests or responses based on certain conditions.
Many HTTP libraries, like the once-popular axios, offer a convenient API to add interceptors without resorting to monkey patching, which should be used sparingly. It’s all too easy to introduce subtle bugs unintentionally or create conflicts when managing multiple overrides.
With the interceptor in place, returning a fake response is as simple as calling the static JSON method of the Response object:
const contactForm7Response = (formData) => { const body = {} return Response.json(body); };Depending on the need, the response can be anything from plain text to a Blob or ArrayBuffer. It’s also possible to specify custom status codes and include additional headers.
For the CodePen demo, the response might be built like this:
const contactForm7Response = (formData) => { const submissionSuccess = { into: "#", status: "mail_sent", message: "Thank you for your message. It has been sent.!", posted_data_hash: "d52f9f9de995287195409fe6dcde0c50" }; const submissionValidationFailed = { into: "#", status: "validation_failed", message: "One or more fields have an error. Please check and try again.", posted_data_hash: "", invalid_fields: [] }; if (!formData.get("somebodys-name")) { submissionValidationFailed.invalid_fields.push({ into: "span.wpcf7-form-control-wrap.somebodys-name", message: "This field is required.", idref: null, error_id: "-ve-somebodys-name" }); } // Or a more thorough way to check the validity of an email address if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.get("any-email"))) { submissionValidationFailed.invalid_fields.push({ into: "span.wpcf7-form-control-wrap.any-email", message: "The email address entered is invalid.", idref: null, error_id: "-ve-any-email" }); } // The rest of the validations... const body = !submissionValidationFailed.invalid_fields.length ? submissionSuccess : submissionValidationFailed; return Response.json(body); };At this point, any fetch call to a URL matching wp-json/contact-form-7 returns the faked success or validation errors, depending on the form input.
Now let’s contrast that with the mocked API server approach.
Mocked API server with serverlessRunning a traditionally hosted mock API server reintroduces concerns around availability, maintenance, and cost. Even though the hype around serverless functions has quieted, we can sidestep these issues by using them.
And with DigitalOcean Functions offering a generous free tier, creating mocked APIs is practically free and requires no more effort than manually mocking them.
For simple use cases, everything can be done through the Functions control panel, including writing the code in the built-in editor. Check out this concise presentation video to see it in action:
For more complex needs, functions can be developed locally and deployed using doctl (DigitalOcean’s CLI).
To return the mocked response, it’s easier if we create a separate Function for each endpoint, since we can avoid adding unnecessary conditions. Fortunately, we can stick with JavaScript (Node.js), and starting with nearly the same base we used for contactForm7Response:
function main(event) { const body = {}; return { body }; }We must name the handler function main, which is invoked when the endpoint is called. The function receives the event object as its first argument, containing the details of the request. Once again, we could return anything, but to return the JSON response we need, it’s enough to simply return an object.
We can reuse the same code for creating the response as-is. The only difference is that we have to extract the form input data from the event as FormData ourselves:
function main(event) { // How do we get the FormData from the event? const formData = new FormData(); const submissionSuccess = { // ... }; const submissionValidationFailed = { // ... }; if (!formData.get("somebodys-name")) { submissionValidationFailed.invalid_fields.push({ // ... }); } // Or a more thorough way to check the validity of an email address if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.get("any-email"))) { submissionValidationFailed.invalid_fields.push({ // ... }); } // The rest of the validations... const body = !submissionValidationFailed.invalid_fields.length ? submissionSuccess : submissionValidationFailed; return { body }; }As far as converting the data, serverless functions typically expect JSON inputs, so for other data types an extra parsing step is required. As it happens, the forms in the CodePen demos are submitted as multipart/form-data.
Without any libraries, we can convert a multipart/form-data string into a FormData by taking advantage of the Response API’s capabilities:
async function convertMultipartFormDataToFormData(data) { const matches = data.match(/^\s*--(\S+)/); if (!matches) { return new FormData(); } const boundary = matches[1]; return new Response(data, { headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` } }).formData(); }The code is mostly focused on extracting the boundary variable. This is typically autogenerated, for example, when submitting a form in a browser.
The submitted raw data is available via event.http.body, but since it’s base64-encoded, we need to decode it first:
async function main(event) { const formData = await convertMultipartFormDataToFormData( Buffer.from(event?.http?.body ?? "", "base64").toString("utf8") ); // ... const body = !submissionValidationFailed.invalid_fields.length ? submissionSuccess : submissionValidationFailed; return { body }; }And that’s it. With this approach, all that’s left is to replace calls to the original APIs with calls to the mocked ones.
Closing thoughtsUltimately, both approaches help decouple the demos from the third-party API dependency. In terms of effort, at least for this specific example, they seem comparable.
It’s hard to beat the fact that there’s no external dependency with the manual mocking approach, not even on something we somewhat control, and everything is bundled together. In general, without knowing specific details, there are good reasons to favor this approach for small, self-contained demos.
But using a mocked server API also has its advantages. A mocked server API can power not only demos, but also various types of tests. For more complex needs, a dedicated team working on the mocked server might prefer a different programming language than JavaScript, or they might opt to use a tool like WireMock instead of starting from scratch.
As with everything, it depends. There are many criteria to consider beyond what I’ve just mentioned.
I also don’t think this approach necessarily needs to be applied by default. After all, I had the CodePen demos working for four years without any issues.
The important part is having a way to know when demos break (monitoring), and when they do, having the right tools at our disposal to handle the situation.
Keeping Article Demos Alive When Third-Party APIs Die originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Making a Masonry Layout That Works Today
Many CSS experts have weighed heavily on possible syntaxes for a new masonry layout feature last year. There were two main camps and a third camp that strikes a balance between the two:
- Use display: masonry
- Use grid-template-rows: masonry
- Use item-pack: collapse
I don’t think they’ve came up with a resolution yet. But you might want to know that Firefox already supports the masonry layout with the second syntax. And Chrome is testing it with the first syntax. While it’s cool to see native support for CSS Masonry evolving, we can’t really use it in production if other browsers don’t support the same implementation…
So, instead of adding my voice to one of those camps, I went on to figure out how make masonry work today with other browsers. I’m happy to report I’ve found a way — and, bonus! — that support can be provided with only 66 lines of JavaScript.
In this article, I’m gonna show you how it works. But first, here’s a demo for you to play with, just to prove that I’m not spewing nonsense. Note that there’s gonna be a slight delay since we’re waiting for an image to load first. If you’re placing a masonry at the top fold, consider skipping including images because of this!
Anyway, here’s the demo:
CodePen Embed Fallback What in the magic is this?!Now, there are a ton of things I’ve included in this demo, even though there are only 66 lines of JavaScript:
- You can define the masonry with any number of columns.
- Each item can span multiple columns.
- We wait for media to load before calculating the size of each item.
- We made it responsive by listening to changes with the ResizeObserver.
These make my implementation incredibly robust and ready for production use, while also way more flexible than many Flexbox masonry knockoffs out there on the interwebs.
Now, a hot tip. If you combine this with Tailwind’s responsive variants and arbitrary values, you can include even more flexibility into this masonry grid without writing more CSS.
Okay, before you get hyped up any further, let’s come back to the main question: How the heck does this work?
Let’s start with a polyfillFirefox already supports masonry layouts via the second camp’s syntax. Here’s the CSS you need to create a CSS masonry grid layout in Firefox.
.masonry { display: grid; grid-template-columns: repeat( auto-fit, minmax(min(var(--item-width, 200px), 100%), 1fr) ); grid-template-rows: masonry; grid-auto-flow: dense; /* Optional, but recommended */ }Since Firefox already has native masonry support, naturally we shouldn’t mess around with it. The best way to check if masonry is supported by default is to check if grid-template-rows can hold the masonry value.
function isMasonrySupported(container) { return getComputedStyle(container).gridTemplateRows === 'masonry' }If masonry is supported, we’ll skip our implementation. Otherwise, we’ll do something about it.
const containers = document.querySelectorAll('.masonry') containers.forEach(async container => { if (isMasonrySupported(container)) return }) Masonry layout made simpleNow, I want to preface this segment that I’m not the one who invented this technique.
I figured out this technique when I was digging through the web, searching for possible ways to implement a masonry grid today. So kudos goes to the unknown developer who developed the idea first — and perhaps me for understanding, converting, and using it.
The technique goes like this:
- We set grid-auto-rows to 0px.
- Then we set row-gap to 1px.
- Then we get the item’s height through getBoundingClientRect.
- We then size the item’s “row allocation” by adding the height the column-gap value together.
This is really unintuitive if you’ve been using CSS Grid the standard way. But once you get this, you can also grasp how this works!
Now, because this is so unintuitive, we’re gonna take things step-by-step so you see how this whole thing evolves into the final output.
Step by stepFirst, we set grid-auto-rows to 0px. This is whacky because every grid item will effectively have “zero height”. Yet, at the same time, CSS Grid maintains the order of the columns and rows!
containers.forEach(async container => { // ... container.style.gridAutoRows = '0px' })Second, we set row-gap to 1px. Once we do this, you begin to notice an initial stacking of the rows, each one one pixel below the previous one.
containers.forEach(async container => { // ... container.style.gridAutoRows = '0px' container.style.setProperty('row-gap', '1px', 'important') })Third, assuming there are no images or other media elements in the grid items, we can easily get the height of each grid item with getBoundingClientRect.
We can then restore the “height” of the grid item in CSS Grid by substituting grow-row-end with the height value. This works because each row-gap is now 1px tall.
When we do this, you can see the grid beginning to take shape. Each item is now (kinda) back at their respective positions:
containers.forEach(async container => { // ... let items = container.children layout({ items }) }) function layout({ items }) { items.forEach(item => { const ib = item.getBoundingClientRect() item.style.gridRowEnd = `span ${Math.round(ib.height)}` }) }We now need to restore the row gap between items. Thankfully, since masonry grids usually have the same column-gap and row-gap values, we can grab the desired row gap by reading column-gap values.
Once we do that, we add it to grid-row-end to expand the number of rows (the “height”) taken up by the item in the grid:
containers.forEach(async container => { // ... const items = container.children const colGap = parseFloat(getComputedStyle(container).columnGap) layout({ items, colGap }) }) function layout({ items, colGap }) { items.forEach(item => { const ib = item.getBoundingClientRect() item.style.gridRowEnd = `span ${Math.round(ib.height + colGap)}` }) }And, just like that, we’ve made the masonry grid! Everything from here on is simply to make this ready for production.
Waiting for media to loadTry adding an image to any grid item and you’ll notice that the grid breaks. That’s because the item’s height will be “wrong”.
It’s wrong because we took the height value before the image was properly loaded. The DOM doesn’t know the dimensions of the image yet. To fix this, we need to wait for the media to load before running the layout function.
We can do this with the following code (which I shall not explain since this is not much of a CSS trick 😅):
containers.forEach(async container => { // ... try { await Promise.all([areImagesLoaded(container), areVideosLoaded(container)]) } catch(e) {} // Run the layout function after images are loaded layout({ items, colGap }) }) // Checks if images are loaded async function areImagesLoaded(container) { const images = Array.from(container.querySelectorAll('img')) const promises = images.map(img => { return new Promise((resolve, reject) => { if (img.complete) return resolve() img.onload = resolve img.onerror = reject }) }) return Promise.all(promises) } // Checks if videos are loaded function areVideosLoaded(container) { const videos = Array.from(container.querySelectorAll('video')) const promises = videos.map(video => { return new Promise((resolve, reject) => { if (video.readyState === 4) return resolve() video.onloadedmetadata = resolve video.onerror = reject }) }) return Promise.all(promises) }Voilà, we have a CSS masnory grid that works with images and videos!
Making it responsiveThis is a simple step. We only need to use the ResizeObserver API to listen for any change in dimensions of the masonry grid container.
When there’s a change, we run the layout function again:
containers.forEach(async container => { // ... const observer = new ResizeObserver(observerFn) observer.observe(container) function observerFn(entries) { for (const entry of entries) { layout({colGap, items}) } } })This demo uses the standard Resize Observer API. But you can make it simpler by using the refined resizeObserver function we built the other day.
containers.forEach(async container => { // ... const observer = resizeObserver(container, { callback () { layout({colGap, items}) } }) })That’s pretty much it! You now have a robust masonry grid that you can use in every working browser that supports CSS Grid!
Exciting, isn’t it? This implementation is so simple to use!
Masonry grid with Splendid LabzIf you’re not adverse to using code built by others, maybe you might want to consider grabbing the one I’ve built for you in Splendid Labz.
To do that, install the helper library and add the necessary code:
# Installing the library npm install @splendidlabz/styles /* Import all layouts code */ @import '@splendidlabz/layouts'; // Use the masonry script import { masonry } from '@splendidlabz/styles/scripts' masonry()One last thing: I’ve been building a ton of tools to help make web development much easier for you and me. I’ve parked them all under the Splendid Labz brand — and one of these examples is this masonry grid I showed you today.
If you love this, you might be interested in other layout utilities that makes layout super simple to build.
Now, I hope you have enjoyed this article today. Go unleash your new CSS masonry grid if you wish to, and all the best!
Making a Masonry Layout That Works Today originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Prompt Building User Interfaces
Perhaps the biggest problem facing AI products today is: people don't know all the things these products can do nor how to get the best results out of them. Not surprising when you consider most AI product interfaces are just empty text fields asking "what do you want to do?". Prompt building user interfaces can help answer that question and more.
We've been exploring ways to help people understand what's possible and how to accomplish it in Bench. Bench is AI for everyday work tasks. As such, it can do a lot: search the Web, browse the Web as you (with a browser extension), generate reports, make PowerPoint, use your email, and many more of the things that make up people's daily work tasks. The problem is... that's a lot.
To give people a better sense of what Bench can do, we started with suggested prompts (aka instructions) that accomplished specific work tasks. To make these as relevant as possible, we added an initial screen to the Bench start experience asking people to specify their primary roles at work: Engineering, Design, Sales, etc. If they did, the suggested prompts would be reflective of the kinds of things they might do at work. For example Sales folks would see suggestions like: research a prospect, prep for a sales meeting, summarize customer feedback, and so on.
The problem with these kinds of high level suggestions is they are exactly that: too high level. Though relevant to a role, they're not relevant to someone's current work tasks. Sales teams are researching prospects but doing it in a way that's specific to the product they're selling and the prospect they're researching. Generic prompt suggestions aren't that useful.
To account for this, we attempted to personalize the role-based suggestions by researching people's companies in the background while they signed up. This additional information allowed us to make suggestions more specific to the industry and company people worked for. This definitely made suggested prompts more specific, but it also made them less useful. Researching someone's company gives you some context but not nearly the amount its employees have. Because of this, personalized suggested prompts felt "off". So we went back to more generic suggestions but made them more atomic.
Instead of encompassing a complete work task, atomic suggestions just focused on part of it: where the information for a work task was coming from (look at my Gmail, search my Notion) and what the output of a work task should be (create a Word Doc, make a chart). These suggestions gave people a better sense of Bench's capabilities. It can read my calendar, it can make Google sheets. Almost immediately, though, it felt like these atomic suggestions should be combine-able.
To enable this, we made a prompt rewriter that would change based on what atomic suggestions people chose. If they picked Use Salesforce and Create Google Doc, the rewriter would merge these into a single instruction that made sense "Use [variable] from Salesforce to create a Google Doc". This turned the process of writing complex prompts into just clicking suggestions. The way these suggestions were laid out, however, didn't make clear they could be combined like this. They looked and felt like discrete prompts.
Enter the task builder. In the latest version of Bench, atomic suggestions have been expanded and laid out more like the building blocks of a prompt. People can either select what they want to do, use, make, or any combination of the three. The prompt rewriter then stitches together a machine-written prompt along with some optional inputs field people can fill in to provide more details about the work task they want to get done.
This prompt builder UI does a few things for people using Bench. It:
- makes what the product can do clearer
- provides a way to surface new functionality as it's added to the product
- rewrites people's prompts in a way that gets them to better outcomes
- clarifies what people can add to a prompt to make their tasks more effective
While that's a decent amount of good outcomes, design is never done and AI capabilities keep improving. As a result, I'm sure we're not done with not only Bench's task builder UI but solutions to discoverability and prompting in AI products overall. In other words... more to come.
Prompt Building User Interfaces
Perhaps the biggest problem facing AI products today is: people don't know all the things these products can do nor how to get the best results out of them. Not surprising when you consider most AI product interfaces are just empty text fields asking "what do you want to do?". Prompt building user interfaces can help answer that question and more.
We've been exploring ways to help people understand what's possible and how to accomplish it in Bench. Bench is AI for everyday work tasks. As such, it can do a lot: search the Web, browse the Web as you (with a browser extension), generate reports, make PowerPoint, use your email, and many more of the things that make up people's daily work tasks. The problem is... that's a lot.
To give people a better sense of what Bench can do, we started with suggested prompts (aka instructions) that accomplished specific work tasks. To make these as relevant as possible, we added an initial screen to the Bench start experience asking people to specify their primary roles at work: Engineering, Design, Sales, etc. If they did, the suggested prompts would be reflective of the kinds of things they might do at work. For example Sales folks would see suggestions like: research a prospect, prep for a sales meeting, summarize customer feedback, and so on.
The problem with these kinds of high level suggestions is they are exactly that: too high level. Though relevant to a role, they're not relevant to someone's current work tasks. Sales teams are researching prospects but doing it in a way that's specific to the product they're selling and the prospect they're researching. Generic prompt suggestions aren't that useful.
To account for this, we attempted to personalize the role-based suggestions by researching people's companies in the background while they signed up. This additional information allowed us to make suggestions more specific to the industry and company people worked for. This definitely made suggested prompts more specific, but it also made them less useful. Researching someone's company gives you some context but not nearly the amount its employees have. Because of this, personalized suggested prompts felt "off". So we went back to more generic suggestions but made them more atomic.
Instead of encompassing a complete work task, atomic suggestions just focused on part of it: where the information for a work task was coming from (look at my Gmail, search my Notion) and what the output of a work task should be (create a Word Doc, make a chart). These suggestions gave people a better sense of Bench's capabilities. It can read my calendar, it can make Google sheets. Almost immediately, though, it felt like these atomic suggestions should be combine-able.
To enable this, we made a prompt rewriter that would change based on what atomic suggestions people chose. If they picked Use Salesforce and Create Google Doc, the rewriter would merge these into a single instruction that made sense "Use [variable] from Salesforce to create a Google Doc". This turned the process of writing complex prompts into just clicking suggestions. The way these suggestions were laid out, however, didn't make clear they could be combined like this. They looked and felt like discrete prompts.
Enter the task builder. In the latest version of Bench, atomic suggestions have been expanded and laid out more like the building blocks of a prompt. People can either select what they want to do, use, make, or any combination of the three. The prompt rewriter then stitches together a machine-written prompt along with some optional inputs field people can fill in to provide more details about the work task they want to get done.
This prompt builder UI does a few things for people using Bench. It:
- makes what the product can do clearer
- provides a way to surface new functionality as it's added to the product
- rewrites people's prompts in a way that gets them to better outcomes
- clarifies what people can add to a prompt to make their tasks more effective
While that's a decent amount of good outcomes, design is never done and AI capabilities keep improving. As a result, I'm sure we're not done with not only Bench's task builder UI but solutions to discoverability and prompting in AI products overall. In other words... more to come.