Problem

A code block copy button behaved correctly on desktop but awkwardly on mobile.

On mobile, tapping a code block could reveal the copy button on the right side. After that, tapping again did not reliably make the button disappear. The button looked like a toggleable control, but it was not actually controlled by a toggle state.

The visible symptom was simple:

  1. The copy button was hidden at first.
  2. A tap made the copy button appear.
  3. Another tap did not reliably hide it.
  4. The button could remain visible in a way that felt stuck.

The important detail is that the copy button was not broken because copying failed. The broken part was the visibility state.

Original Behavior

The site uses Quartz with a syntax-highlighting plugin. That plugin adds a copy button to code blocks.

The original interaction model was designed around desktop hover:

.clipboard-button {
  opacity: 0;
}
 
pre:hover > .clipboard-button {
  opacity: 1;
}

That model is reasonable for a mouse.

On desktop:

  • the pointer enters a code block
  • the browser applies pre:hover
  • the copy button appears
  • the pointer leaves the code block
  • the hover state ends
  • the copy button disappears

There is no explicit toggle. The browser’s hover state is the state.

Why It Fails on Mobile

Mobile touch does not map cleanly to desktop hover.

A finger can tap, press, scroll, or focus an element, but it does not hover in the same way a mouse cursor does. Some mobile browsers still apply hover-like behavior after a tap for compatibility with desktop-oriented pages.

That creates a strange middle state:

  • the user taps a code block
  • the browser applies a hover-like state
  • the copy button appears
  • the user lifts their finger
  • the hover-like state may remain

From the user’s point of view, the button appeared because of a tap. So the next natural expectation is that another tap should hide it.

But the original implementation never promised a tap toggle. It only promised hover visibility. On mobile, that means the button can feel sticky.

The root cause is therefore:

The mobile interaction depended on CSS hover, but touch devices do not provide a reliable hover lifecycle.

The First Attempt Was Wrong

The first attempted fix made the copy button lightly visible by default on mobile.

That avoided the exact symptom of “tap once and the button appears forever”, but it did not preserve the intended interaction.

The result was:

  • the button was visible before the user asked for it
  • tapping did not act like a show/hide control
  • the code block had extra visual noise on mobile

That was not the right fix because it changed the problem instead of solving it.

The better target behavior was:

User actionExpected mobile result
Before interactionButton hidden
Tap code block onceButton visible
Tap code block againButton hidden
Tap copy buttonCopy runs, then button hidden
Tap elsewhereButton hidden

This required an explicit mobile state, not a permanently visible button.

Final Approach

The final fix separates desktop and mobile behavior.

Desktop keeps the original hover interaction. Mobile stops relying on hover and uses an explicit state class instead.

The rule is:

Desktop can use hover because mouse hover has a real enter/leave lifecycle. Mobile should use tap-controlled state because touch hover can become sticky.

The explicit state class is:

is-copy-button-visible

On mobile:

  • if the code block has this class, the copy button is visible
  • if the code block does not have this class, the copy button is hidden

That turns the visibility behavior from an accidental browser hover state into a state the site controls directly.

CSS Change

The CSS override applies only to touch-like environments:

@media (hover: none), (pointer: coarse) {
  html body pre > .clipboard-button {
    opacity: 0;
    pointer-events: none;
  }
 
  html body pre:hover > .clipboard-button,
  html body pre:focus-within > .clipboard-button {
    opacity: 0;
  }
 
  html body pre.is-copy-button-visible > .clipboard-button {
    opacity: 1;
    pointer-events: auto;
  }
}

This is the most important part of the fix.

The media query means:

  • apply this rule when the device has no reliable hover
  • or when the primary pointer is coarse, such as a finger

The first rule hides the copy button:

opacity: 0;
pointer-events: none;

pointer-events: none matters because a fully transparent button is still a real element. Without this line, the hidden button could still intercept taps.

The second rule neutralizes sticky hover:

pre:hover > .clipboard-button {
  opacity: 0;
}

This says: on mobile, do not let pre:hover reveal the button.

The third rule introduces the real mobile visible state:

pre.is-copy-button-visible > .clipboard-button {
  opacity: 1;
  pointer-events: auto;
}

Now the button appears only when JavaScript adds the class.

JavaScript Behavior

The JavaScript behavior is small:

  1. Find every pre code block.
  2. Attach one click handler to each code block.
  3. On touch-like devices, toggle is-copy-button-visible.
  4. Hide other visible copy buttons when opening a new one.
  5. Hide the button after the copy button itself is tapped.
  6. Hide all visible copy buttons when the user taps outside code blocks.

The central toggle is:

const willShow = !pre.classList.contains(mobileCodeCopyVisibleClass)
mobileCodeCopyHideAll(pre)
pre.classList.toggle(mobileCodeCopyVisibleClass, willShow)

This sequence does two things.

First, it computes the next state:

const willShow = !pre.classList.contains(mobileCodeCopyVisibleClass)

If the button is hidden, show it. If it is shown, hide it.

Second, it closes other open code block buttons before applying the new state:

mobileCodeCopyHideAll(pre)

That prevents the page from accumulating multiple visible copy buttons.

Why Use a Local Quartz Plugin?

The copy button comes from a Quartz plugin, but the installed plugin code lives in plugin cache. Plugin cache is not a good place for a durable site-specific fix.

Patching cache code would be fragile because:

  • reinstalling plugins could erase the change
  • updating the plugin could overwrite the change
  • the fix would be hidden inside third-party code
  • future maintainers would have a harder time understanding why the site behaves differently

Instead, the fix is stored as a small local Quartz plugin:

plugins/mobile-code-copy-toggle/

The local plugin does not render visible UI. It contributes an afterDOMLoaded script to the Quartz page.

That makes the fix:

  • tracked in the repository
  • isolated from upstream plugin code
  • easy to remove later
  • explicit in the Quartz configuration

Why Keep Desktop Hover?

The desktop behavior was not broken.

On desktop, hover is natural and efficient:

  • users do not see copy buttons everywhere
  • the button appears when the code block is relevant
  • the button disappears when the pointer leaves

Replacing desktop hover with click-to-toggle would make the desktop interaction heavier for no benefit.

The fix therefore keeps desktop hover unchanged and only overrides touch-like environments.

Behavior Matrix

EnvironmentBehavior
Desktop with mouseExisting hover behavior
Mobile touch browserTap code block to show, tap again to hide
TabletUsually tap-toggle behavior
Touch laptopDepends on browser media query result
Mobile emulation in browser toolsTap-toggle behavior when emulating touch/coarse pointer

The important boundary is not screen width by itself. The boundary is whether the environment has reliable hover.

A small desktop window with a mouse should still behave like desktop. A large tablet with touch may behave like mobile. That is why the fix uses interaction media features instead of only viewport width.

Verification

The fix was verified with a real local Quartz serve build, not only by reading the CSS.

The test used:

  • a mobile-sized viewport
  • touch emulation
  • hover: none
  • pointer: coarse
  • the actual affected code block

The tested code block contained:

byte value + interpretation rule + context -> meaning

The verified sequence was:

StepExpected stateVerified state
Initial page stateButton hiddenopacity: 0, no visible class
First tap on code blockButton visibleopacity: 1, visible class present
Second tap on same code blockButton hiddenopacity: 0, visible class removed
Show again, then tap copy buttonButton hidden after copy tapopacity: 0, visible class removed

This mattered because a build-only check would not have proven the interaction. The earlier wrong attempt also built successfully. The useful test was the actual state transition after taps.

General Lesson

CSS hover is a good mechanism when the device truly has hover.

CSS hover is a weak mechanism when the user’s interaction is touch.

When a touch interaction needs to behave like a toggle, use explicit state:

  • a class
  • an attribute
  • or component state

Do not rely on a mobile browser to make desktop hover semantics feel like a tap-controlled UI.

The durable rule is:

If a control appears because of a tap, the user will often expect another tap or an outside tap to dismiss it. That expectation should be represented in explicit UI state, not left to sticky hover behavior.

Future Maintenance Notes

This workaround should be revisited if the upstream syntax-highlighting plugin changes its copy button behavior.

Things to check after plugin updates:

  • Does the button still use .clipboard-button?
  • Is the button still a child of pre?
  • Does the plugin now provide an official mobile/touch option?
  • Does the plugin still use hover-based visibility?
  • Does Quartz still dispatch the same nav and render lifecycle events?

If the upstream plugin gains correct touch behavior, the local plugin can be removed and the CSS override can be simplified or deleted.

Until then, the local fix keeps the desktop experience intact while making the mobile interaction predictable.