On interfaces that let you change your mind

This is a web example, influenced by some things I discovered whilst working in Swift on DuckDuckGo’s iOS toolbar. In that work, different code paths were writing to the same view without agreeing about who owned it. I’ve written up that investigation, and it made me more attentive to what happens when an earlier instruction is still running after the state has changed.

Open a panel, close it and immediately open it again. Does it end up open?

It sounds like a ridiculous thing to need to check. But once closing involves an animation and a delayed cleanup, an earlier instruction can outlive the decision that caused it. The panel starts reopening, then disappears because the first close finally finishes.

Let’s explore that question with a disclosure panel on the web. The implementation and the bug are different from the Swift example, but the question about which instruction owns the current state carries across. Try the broken and corrected versions below.

Interactive demo —

How an old decision catches up

A tempting implementation hides the panel after its closing animation:

function closePanel() {
  panel.dataset.open = "false";
  setTimeout(() => {
    panel.hidden = true;
  }, 240);
}

On its own, that appears to work. Open it again before the timer fires though, and hidden = false doesn’t cancel the pending close. The panel starts reopening, then the old timer comes along and hides it anyway.

Replacing the timer with a completion callback doesn’t automatically settle the ownership question. You still need to know whether the completed animation belongs to the current interaction. You can cancel old work or check which request owns it, and sometimes you need to do exactly that.

For this little panel, though, we can remove the delayed cleanup entirely. The content can stay in the document whilst CSS handles the visible expansion. JavaScript only needs to say whether it’s open and whether its contents can be used.

Keep the current intent in charge

We’ll use an ordinary button followed by its content. The WAI disclosure pattern describes the button’s expanded state and its relationship to the content. This is an inline disclosure, so it doesn’t need dialog semantics or a focus trap.


  

  
    
      
        
          
          Use larger article text
        
        
      
    
  

The two inner wrappers have different jobs. One clips the collapsing content; the other holds the padding. Keeping padding off the grid item lets that item collapse all the way to zero.

.panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 240ms ease-out;
}

.panel[data-open="true"] {
  grid-template-rows: 1fr;
}

.panel-clip {
  min-height: 0;
  overflow: hidden;
}

.panel-content {
  display: grid;
  gap: 1rem;
  padding: 1rem;
}

 

For this single-row grid, the flexible track expands to contain the content at 1fr. At 0fr, the zero minimum on the child allows it to collapse. Without min-height: 0, the content’s minimum size can keep the panel propped open. The nested wrapper is what stops its text and controls spilling out as the row shrinks.

Changing the target during a CSS transition lets it respond from its current interpolated state. That gives this disclosure a useful reversal without measuring pixel heights in JavaScript. It does animate layout, so I’d keep an eye on performance if the panel lived inside a large, expensive page. This example favours a small amount of understandable code over a more elaborate motion system.

The behaviour is just one state update:

const toggle = document.querySelector("#toggle");
const panel = document.querySelector("#panel");
const close = document.querySelector("#close");
let isOpen = false;

function setOpen(nextOpen) {
  if (!nextOpen && panel.contains(document.activeElement)) {
    toggle.focus();
  }

  isOpen = nextOpen;
  toggle.setAttribute("aria-expanded", String(isOpen));
  panel.inert = !isOpen;
  panel.dataset.open = String(isOpen);
}

toggle.addEventListener("click", () => setOpen(!isOpen));
close.addEventListener("click", () => setOpen(false));

Put that script after the markup. The toggle and the close button inside the panel both go through the same function, so they update the same state. Removing the delayed cleanup means there’s nothing waiting to come back and hide the panel after I’ve opened it again.

Closing also changes what you can reach

Clipping the panel makes it disappear visually, but doesn’t by itself make its controls unavailable. A keyboard user could otherwise tab into a checkbox they can’t see.

The inert attribute removes the panel’s contents from keyboard focus and the accessibility tree whilst it’s closed. We update it immediately when closing starts. For this interaction, the shrinking content is finishing its exit, so it shouldn’t keep accepting input during that exit.

Focus needs a little care too. If you use the close button inside the panel, we move focus back to the toggle before making the contents inert. If something elsewhere closes the panel whilst focus is already outside it, we leave focus where it is. Closing one part of a page shouldn’t unexpectedly pull you back from another.

Opening keeps focus on the toggle, and Tab moves into the revealed content. That suits this disclosure. A modal would need a different focus model, which is why I wouldn’t turn this snippet into a general-purpose overlay component.

The reduced-motion version uses exactly the same state changes with the transition removed. It doesn’t depend on an animation ending to become correct. For a simpler disclosure with no bespoke motion requirement, I’d also consider native details and summary before writing custom behaviour at all.

Try opening the example, closing it halfway through and reopening immediately. Then tab into it, use the internal close button and check where focus lands. Repeat with reduced motion enabled. Those few actions cover considerably more than watching it open once.

Fellow design engineer Jakub Krehel includes interruptibility in his interface details, and I think this is a useful extension of that idea. I want the panel’s position, controls and expanded state to keep agreeing, even when I change my mind halfway through. Try changing your mind a few times while it’s moving; that’s where this example starts to get interesting.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论