Links that jump to headings on the page look simple.
<a href="#details">Go to details</a>
<section id="details">...</section>
When you click this link, the URL’s end changes to #details, and the corresponding element becomes the destination within the page. By using :target in CSS, you can highlight only that element.
:target {
outline: 2px solid red;
}
Usually, understanding that “the part of the URL after # matches id” is sufficient. However, when trying to incorporate :target into my own browser, Omoikane, that wasn’t enough. The URL‑parsing logic, the HTML rules for selecting the target, the document’s internal state, CSS selector matching, and the updates after changing location.hash are all handled in separate places.
What was especially interesting was that the condition for :target isn’t recomputed from the URL and id inside a CSS function each time. Instead, the HTML side determines the target element in the document, and CSS simply references that state. This time, we’ll trace the processing steps that lie between them: the addition of target elements and the handling of fragment navigation within the same document.
What changes first
Consider the following HTML.
<style>
section:target { color: rgb(13, 42, 71); }
</style>
<section id="first">First section</section>
<section id="second">Second section</section>
If the URL is https://example.test/page#first, the element with first matches :target. When the URL changes to #second, the matched element also shifts accordingly. Not only does the color of getComputedStyle() change, but the results of document.querySelector(':target') and element.matches(':target') also change. Implementing these with separate rules can lead to inconsistencies, such as the screen showing a new color while the DOM query still returns the old element.
There are two distinct tasks here. One is to determine, from the URL, which element is the target of this document. The other is to reflect that chosen target in CSS and DOM selector matching. When the URL changes, the former must be recomputed, and the latter must be prevented from using stale results.
flowchart TB
URL["Document's URL<br/>#second"] --> HTML["Select target according to HTML rules"]
HTML --> STATE["Document's target element<br/>the second element"]
STATE --> MATCH["Selector matching<br/>:target"]
MATCH --> STYLE["CSS styling and rendering"]
MATCH --> DOM["querySelector / matches"]
In the diagram above, the box before CSS becomes important. Rather than passing the URL itself to each CSS call, we first update the document’s state and then perform the matching.
:target is not a rule limited to CSS
The :target pseudo‑class defined in Selectors Level 4 is a pseudo‑class that matches the element targeted by the document’s URL. It does not mean that any element will match simply because a fragment like #details is present. If no target element is selected, there will be no element that matches :target. (source)
So, who decides which element is the target? In an HTML document, the procedure for fragments described in the HTML Standard determines it. The specification mentions both a “Document’s indicated part” and a “target element.” The former refers to the portion indicated by the URL’s fragment; it is usually an element, but it can also be a special value that points to the start of the document. The latter is the target element of the Document that is used for :target determination. (documentation)
This distinction becomes clear when considering https://example.test/page#. An empty fragment is treated as pointing to the start of the document, but the “start of the document” is a special value that is not an HTML element. Therefore, merely attaching an empty fragment does not automatically cause html or body to match :target. The destination within the page and the element that matches in CSS may be similar, but they are not the same concept.
Also, :target differs from :hover and :focus. It is not a state such as whether the mouse is over an element or whether it is the keyboard focus target; rather, it is a state tied to the Document’s URL. Even for an iframe whose Document has a different URL, the parent page’s #details must not target the same element.
Fragments and id cannot be compared directly
If you describe the target‑selection process as “compare location.hash.slice(1) with each element’s id,” ordinary cases work fine. However, the HTML specification defines a specific order for matching.
First, candidates are searched using the fragment as‑is. If an element with the same id value exists, the first one in document order is chosen. If no such id is found, the legacy <a name="..."> attribute is also considered. If still nothing is found, the fragment is percent‑decoded, interpreted as UTF‑8, and the same search is performed again. The specification also includes a special case for top after that. The HTML Standard’s selection procedure (documentation) specifies this entire order.
flowchart TB
F["URL fragment"] --> RAW["Search using the original string"]
RAW --> ID1{"Is there a matching ID?"}
ID1 -->|Yes| HIT1["First element in document order"]
ID1 -->|No| NAME1{"Is there a matching a[name]?"}
NAME1 -->|Yes| HIT2["First a element in document order"]
NAME1 -->|No| DEC["Percent‑decode and re‑search"]
DEC --> ID2["Prioritize ID, then a[name]"]
ID2 --> RESULT["Element, or no target"]
For example, suppose the URL is #encoded%20value and the page contains the following two elements.
<div id="encoded%20value">Raw fragment</div>
<div id="encoded value">Decoded fragment</div>
The first element is selected. If the %20 is converted to an empty string first, the second element will be chosen instead. Omoikane’s regression test (source code) includes cases where the raw string takes precedence, and cases where, only when there is no candidate in the raw string, the search proceeds to the decoded id.
There is also an ordering between id and a[name].
<a name="legacy">Legacy anchor</a>
<div id="legacy">Element with an ID</div>
If the URL is #legacy, the div element with an id is selected, not the earlier a. Simply walking the document and returning the first string match would be incorrect. Duplicate id values use the first element in document order, but between an id and the legacy name attribute, the attribute type takes priority.
Omoikane’s find_fragment_target first searches for candidates using the raw fragment, and if none are found, it searches again after percent‑decoding. During the search it remembers any matching a[name], and if a id is discovered, it returns that instead. The tests mentioned above also include empty fragments, non‑matching fragments, UTF‑8 percent‑encoded fragments, and duplicate id.
The target is maintained per Document
Even though the element is determined by the URL, we didn’t design the system to scan the entire DOM for every CSS match. In Omoikane, each Document’s identifier holds a weak reference to the currently selected element in HostState. This separates the URL‑reading logic from the :target matching.
There is a reason to use a weak reference. We don’t want the fragment‑related record to keep holding a Node after the element that was selected as the target of navigation has been removed from the document tree. When updating the target, we first examine the previous reference, remove the target state from the old element, and attach it to the new element. If the target disappears, we also erase the Document’s record. Any lingering state is cleaned up when the runtime exits.
This logic is implemented in Omoikane’s update_document_target (source code). It is called not only for the initial Document but also for child iframe Documents and for any path that creates a new browsing context. Even if the same string #details appears in both the parent and the child, the target is determined separately from each Document’s URL. Thus, a change to the parent’s URL does not cause the child’s :target to be updated—this defines the boundary between the two.
When selecting a location that has a target, it’s easy to initially think that “just checking the Node’s attributes” is enough because the Node has id. However, id is merely information for locating candidates, and whether that element is currently selected depends on the URL and the Document’s state. Even if an element with id="details" exists, if the URL is #other, it will not be :target. Even when the URL’s state is attached to the Node, the entity responsible for managing the selection must reside in the Document.
Returning the Same Result for CSS and DOM Queries
Omoikane marks the selected Node with a target state and reads that state through a common selector match. In src/css/matcher.rs, it checks whether :target matches the Node’s target state. Not only CSS style application but also querySelector(), querySelectorAll(), and matches() go through this matching process.
In the test where the initial URL is #first, we verify the following together:
const target = document.querySelector(':target');
const matches = target?.matches(':target');
const color = target && getComputedStyle(target).color;
Even if only target is correct at this point, if color remains unchanged, the CSS side will not be applied. Conversely, if only the color changes but querySelector(':target') becomes null, the DOM selector search and the CSS matching will be looking at different states. Regression tests examine, for a single input URL, the selected Node, selector matches, DOM searches, and the resulting computed color all at once.
As a prerequisite, the parser must accept :target as a valid pseudo‑class. In the location‑related pseudo‑class parsing fix, we added support for recognizing :link, :visited, :any-link, and :target. Accepting the name and correctly determining which elements actually match are separate steps. Invalid forms with arguments, such as a:target(x), are rejected. Even when passed to querySelector(), we do not silently treat an ill‑formed selector as “no match”; instead, we treat it as a syntax error.
From the CSS perspective, :target is part of a selector, but determining its truth value requires both the HTML target selection and the Document’s state. Even if only the parser is fixed or only the URL‑updating path is corrected, the behavior observed on the page will not align.
Changing location.hash Is Complicated
If the initial URL alone matches :target, the implementation is relatively straightforward. The difficulty arises when only the fragment changes while the Document remains the same. For example, on a page displaying #first, a script might execute the following:
location.hash = '#second';
The Document does not become new. Not only the CSS target but also the URL, history, hashchange, and other aspects must be treated as a transition within the same Document. Omoikane’s location.hash assignment constructs a new URL and requests navigation. It does not simply finish by updating a property of a JavaScript object.
In the same‑Document navigation fix, a pathway was added that, after the CDP accepts a URL change, passes the finalized URL to the JavaScript runtime. The runtime updates the current Document’s URL and re‑selects the target based on that URL. In the implementation order, we separate “receiving an assignment to location.hash” from “finalizing the URL navigation.” If the CSS state is updated in advance as soon as the assignment is seen, the URL, history, and :target may end up representing different points in time.
flowchart TB
JS["location.hash = '#second'"] --> NAV["Navigation within the same Document"]
NAV --> COMMIT["Confirm the new URL"]
COMMIT --> SELECT["Re‑select the target"]
SELECT --> SWAP["Unset first<br/>Set second as target"]
SWAP --> CACHE["Invalidate the document's style cache"]
CACHE --> OBS["New results for CSS and DOM searches"]
One might think that simply reassigning the target would be enough, but the previously computed styles remain outdated. Omoikane’s update_document_target invalidates the Document’s style cache when the target changes. This allows subsequent getComputedStyle() and rendering to use the new :target match results. If the target remains the same, unnecessary invalidation is avoided.
In the CDP‑based regression test, we first navigate to #section, then execute location.hash = '#next', and finally clear the fragment. For each step we verify the counts of location.hash and hashchange, the values of querySelector(':target'), the old and new targets’ matches(':target'), and the computed color. The same id="section" element is also placed in a child iframe, but the parent’s navigation does not cause the child to become :target.
The interesting aspect of this test is not that each API works in isolation, but whether they observe the same navigation. If the URL is #next yet querySelector(':target') returns section, or if the DOM search yields next while the color remains section, some state update or invalidation is missing.
:target and Scrolling Are Not the Same Task
When discussing fragment links, one naturally also thinks of scrolling to the target. However, the HTML Standard defines not only the selection of the target but also the procedures for scrolling and focusing to display it. Simply having the CSS :target match does not guarantee that the scroll position is correct.
The fixes and tests we pursued this time focus on target selection, URL changes within the same Document, and the consistency of CSS and DOM results. Within this scope, we would not claim that we have fully implemented all fragment‑navigation specifications. The specification also includes special handling for empty fragments and top, and interactions with scrolling, focus, and history require additional verification.
Even when reading the implementation, it’s better not to reduce it to a single line like “when #details is present, look for the corresponding element.” By separating the navigation destination for display, the CSS target element, the Document’s state and the Node’s attributes, the initial URL from the later‑changing URL, you can clearly see which test guarantees what.
What Was Verified
The new tests at least cover the following boundaries.
| Input / Action | What is verified |
|---|---|
Initial URL containing #first | CSS, DOM search, and matches() select the same element |
#encoded%20value | Look for the pre‑decoded id first |
#only%20decoded | If no candidate exists in the raw value, search after decoding |
id and a[name] have the same value | Prefer id even if it appears later in the Document |
Duplicate id | Choose the first element in document order |
Change from #section to #next | Remove the old target and update DOM search and styles |
| Clear the fragment | No target remains, and previous styles are removed |
Same id in parent and child iframes | Parent’s URL change does not propagate to the child Document |
Each case is individually small, but they are arranged to exercise the intersections of URL, HTML, CSS, and JavaScript. Tests that compare only the visual appearance of images cannot detect mismatches in querySelector(':target'). Tests that compare only DOM values may overlook outdated colors remaining in the style cache. Both checks are therefore necessary.
Summary
:target is a short CSS pseudo‑class, but its result cannot be produced solely by CSS. HTML selects the target element in the Document from the URL fragment, and Omoikane retains that state per Document. CSS matching and DOM searching read the same state, and when the URL changes, the target is reassigned and the old styles are invalided.
What stood out during implementation were the order of the raw fragment versus the decoded fragment, the priority between id and a[name], and cases where the URL changes while the Document remains the same. None of these would be apparent in ordinary #details usage, yet they must be defined for consistent browser behavior.
Even though it may seem like a minor CSS feature, behind the scenes the HTML element selection and navigation state are at work. In Omoikane, we have now implemented and tested the functionality up to the point where :target can be observed in the same way from both CSS and the DOM.