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:
- The copy button was hidden at first.
- A tap made the copy button appear.
- Another tap did not reliably hide it.
- 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 action | Expected mobile result |
|---|---|
| Before interaction | Button hidden |
| Tap code block once | Button visible |
| Tap code block again | Button hidden |
| Tap copy button | Copy runs, then button hidden |
| Tap elsewhere | Button 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-visibleOn 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:
- Find every
precode block. - Attach one click handler to each code block.
- On touch-like devices, toggle
is-copy-button-visible. - Hide other visible copy buttons when opening a new one.
- Hide the button after the copy button itself is tapped.
- 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
| Environment | Behavior |
|---|---|
| Desktop with mouse | Existing hover behavior |
| Mobile touch browser | Tap code block to show, tap again to hide |
| Tablet | Usually tap-toggle behavior |
| Touch laptop | Depends on browser media query result |
| Mobile emulation in browser tools | Tap-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: nonepointer: coarse- the actual affected code block
The tested code block contained:
byte value + interpretation rule + context -> meaningThe verified sequence was:
| Step | Expected state | Verified state |
|---|---|---|
| Initial page state | Button hidden | opacity: 0, no visible class |
| First tap on code block | Button visible | opacity: 1, visible class present |
| Second tap on same code block | Button hidden | opacity: 0, visible class removed |
| Show again, then tap copy button | Button hidden after copy tap | opacity: 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
navandrenderlifecycle 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.