For this library, the following would be true:
Consider you have this Group setup:
const group = new Group
group.append("A", "B", "C")Then the diagram would be:
Group
├─ A
├─ B
└─ C
document
└─ body // No children.Adding a group to a parent (that is attached to document, it is required due to connectedCallback reliance) would look like that:
const parent = document.createElement("div")
parent.append(group)
// document.body.append(parent) // The order doesn't matter, though for group to appear in the `parent`, must be attached to a `document` in some way.This diagram would represent it:
Group
├─ A
├─ B
└─ C
document
└─ body
├─ A
├─ B
└─ CLet's break down this diagram in terms of properties (interfaces):
- Accessing any properties of
groupwill (should) be 100% same asDocumentFragmentin the moment it is not yet connected anywhere (which flushes its children), expect those that arenullby design likenextSiblingorparent. childNodesofgroupare both ingroupanddocument.body
group.childNodes // ["A", "B", "C"]
document.body.childNodes // ["A", "B", "C"]parentreturn the parent wheregroupwas previously attached to - the same with other properties.
In short, group behaves just like a normal ParentNode.
That was a simple part, let's see what probably confuses people:
groupnode itself can't be find in thedocument, even if attached andparentproperty shows it is. 🤯groupnode doesn't have a wrapper and is completely transparent to CSS selectors, Box View Model and Layout.
In short, group is a virtual node, but faking to be real ParentNode.
Another interpretation would be "a NodeList with ParentNode and ChildNode interfaces".
Unaffected group children for special elements like table, select:
In contradiction to other parent elements, group will never mess up with its children, they are attached as given, you will never face a situation where elements are forcefully formatted in group node when it's being attached since group is a limbo.
Adding group in between other elements:
group.append(A, B, C)
document.body.append(X, Y, Z)⬇️
Group
├─ A
├─ B
└─ C
(document)
└─ body
├─ X
├─ Y
└─ ZNow let's add the group before Y
document.body.insertBefore(group, Y)⬇️
Group
├─ A
├─ B
└─ C
(document)
└─ body
├─ X
├─ A
├─ B
├─ C
├─ Y
└─ ZExpectations
console.log(group.firstChild === A) // true
console.log(A.parentNode === document.body) // true
console.log(X.nextSibling === A) // true
console.log(C.nextSibling === Y) // trueAccessing properties of children that are part of group:
Reading node properties always reflects the "real" tree - not the Group logical ownership.
group.append(A, B, C)⬇️
Group
├─ A
├─ B
└─ C
(document)
└─ body // No children.⬇️
console.log(group.firstChild === A) // true
console.log(A.parentNode === null) // true
console.log(A.isConnected === false) // true
console.log(A.nextSibling === null) // trueNow connect group
document.body.append(group)⬇️
Group
├─ A
├─ B
└─ C
(document)
└─ body
├─ A
├─ B
└─ C⬇️
console.log(group.firstChild === A) // true
console.log(A.parentNode === document.body) // true
console.log(A.isConnected === true) // true
console.log(A.nextSibling === B) // trueWhile this may look confusing and not intuitive, in practice it's not bothering at all since group is treated as NodeList you can put to the document with append method.
| Feature / Expectation | Figma | NodeGroup |
Impact (what breaks in mental model) |
|---|---|---|---|
| Group is a real parent / folder | Group is a first-class parent node in the layer tree. | Group is a logical owner; children remain real DOM nodes with real DOM parents. |
Users expect node.parent === group. They get physical parents instead. Confusion and buggy assumptions. |
| Move group = single operation | Move folder moves children as a single unit in UI and undo stacks. | Moving group inserts/moves each child individually in DOM (multiple DOM ops). | More DOM mutations, more expensive reflows, different undo behavior. |
| Folder-level transforms / masks / blend modes | Transform/mask applies once to the folder and cascades to children. | No inherent group-level composition; must apply transforms per child or wrap in container. | Hard to apply group transforms or masks efficiently. |
| Querying / CSS selectors | Layers/folders can be targeted by UI layer queries. | DOM selectors operate on real DOM only; logical grouping is invisible to CSS selectors. | Selectors, querySelector, and CSS cannot target groups. |
| Event path / pointer capture | Events and selection operate on the folder as a node. | group object does not appear in composedPath(). |
Event handling assumptions break; event.target never shows group. |
| Instances / components | Components and instances are first-class: detach, reset, overrides. | Group is a class with lifecycle inherited from Custom Element. |
OK |
| DevTools visibility | Layers panel shows the folder. | DevTools show only DOM nodes; Group object is invisible unless you add debugging Comment nodes. | Harder to inspect group membership in browser tools. |
| Performance for many children | Folder move is a single conceptual operation; | Inserting many children may trigger many reflows/mutations if not batched. | ? |
| Serialization / export | Groups serialize naturally in layer/export formats. | Group is a logical list; exporting DOM may lose the group unless there is a custom serialization. | Harder to export the group structure for tools or file formats. |
| CSS layout / stacking context | Folder can create a stacking context / transform once for all children. | Without a wrapper each child may need its own stacking context; group-level stacking not automatic. | Visual composition differs from designer expectations. |
Predictable parentNode / DOM navigation |
node.parent matches group. |
node.parentNode, nextSibling, etc., always reflect real DOM. |
Code that navigates DOM expecting group semantics breaks. |
This comparison is not fair though since Group in Figma has a limited inheritance, which makes sense, while in JS DOM we can just use another div wrapper.