microsoft / fast

The adaptive interface system for modern web experiences.
https://www.fast.design
Other
9.16k stars 586 forks source link

rfc: add new adaptive density system #5510

Closed bheston closed 1 month ago

bheston commented 2 years ago

💬 RFC

Here's a look at some of the history, issues, and updated model for adaptive density.

Apologies for the length. It didn't seem as long as the original slide layout, but I think the context is all important to understand the proposal.

Please see the linked PR for design tokens and sample implementation.

Note that the examples use current Fluent UI dimensions, but the system works with any values.

Overview

Density

Balance between information quantity, visual comfort, and usability.

Density refers to the relationship between visual elements and empty space - How content like text, icons, and images is laid out relative to each other, or how container areas are defined by color, line, or other demarcation.

What density is not.

It is important to differentiate density from scaling or zooming. Density is the primary adjustment that can be made to fit more or less content in a given area while maintaining its size and readability. Scaling makes content larger or smaller. While this affects how much content is visible, it doesn’t affect the relationship between the content and the empty space. See Density vs. scaling.

Common motivations.

A few considerations for adjusting interface density are:

A simple example of density configurations affecting vertical spacing:

More dense, normal, and less dense button examples

Due to the nature of horizontal character flow, component width is often a function of its content or layout container, while the height defines its hit target or the number of rows that are visible.

The design unit

Maintaining logic and consistency in component sizing.

The design unit is the number of pixels that form the basis of design decisions made in regards to sizing and layout of components and elements.

In Fluent Design, the design unit is 4 pixels.

The 4px design unit

Describing base density.

The design unit is multiplied with a base value to calculate measurements applied to aspects of interface elements.

For instance, the spacing on the sides of a button, between text and icons, or between buttons are all expressed as a multiple of the design unit.

A normal density button

Adjusting density.

With the relationships applied to components and layouts, density adjustments are applied to scale the base multipliers.

For instance, increasing a multiplier by 1 design unit adds 4px to that measurement, resulting in more empty space within that component.

A less dense button

Component density design tokens

Design tokens affecting internal component density.

A few standard design tokens can handle most element layout needs.

“Component density” describes how spacing is applied within the bounds or footprint of a component.

Spacing - outer and between, horizontal and vertical

Design token names and Fluent Design default values:

Application of density design tokens on buttons

Layout density design tokens

Design tokens affecting component-to-component density.

Interface layout follows the same pattern as components. Component and layout density are split to facilitate high-level adjustments to either individually.

“Layout density” describes how spacing is applied between components.

Spacing - outer and between, horizontal and vertical

Design token names and Fluent Design default values:

Examples of layout density token

History

Problems with the height-based model

Why density is now based on padding instead of height.

Padding allows for variable, unknown content to maintain consistent density.

It also allows for consistent application both horizontally and vertically, instead of using height for vertical density and padding for horizontal density.

Height only works for single-line components.

The previous density model calculated the height for components like buttons and text fields. This appeared to work for these components because they had the expected height and contained the default sized content like text and icons.

This model did not work for other components like text areas or containers because there was no way to describe the height for variable-sized content.

Further issues with content sizing.

Specifying the height also results in layout inconsistencies or overflow when the size of the content changes. For example, making button text larger results in apparent density changes, even causing it to look broken if content is larger than the calculated height allowed.

Typical 20px icon with 14/20 text:

Standard button

The icon and text fits with the expected visual density based on the calculated 32px height (purple).

24px icon with 20/28 text

Dense button

The larger icon and text still fit inside the button, but have also increased the density.

48px icon with 28/36 text:

Overflowing button

The much larger icon flows outside the bounds of the button, as does the descender of the “y”.

Implementation details

Visual intent vs. implementation overview

The density tokens describe the visual intent of the design.

Successfully implementing the design with css is another challenge.

Borders in the box model.

The spacing-based density model provides a robust way to support various sizes of content. The most logical way to implement this spacing on the web is to set css padding on the outer element that declares the boundary of the component.

In the css box model, any border thickness is added outside the padding and will undesirably increase the size of the component.

Components with the same density padding and different border widths will appear different sizes.

Buttons with different borders

In css the padding needs to be adjusted for the border in order to maintain overall size.

This is probably not the intent. The Cancel button is clearly larger than the Save button, but the Save button should visually draw the most attention. This could easily be confused with the Cancel button being focused.

Margins on child elements.

In the base component styling we don’t set exterior margins to avoid assumptions on how they are used, however, some common child elements used as content within a component already have explicit or implied margins.

Icons and text are the most common content, and the most likely to have invisible margins that affect layout.

Many popular icon libraries build margin into their icons for visual balance:

Icons with margin

For example, Fluent uses the margin space for modifiers and Material has balanced the tip of the plane into the margin to account for its thin point.

Note that some libraries have margin in svg, but not in their icon font. In some icon fonts the icons are not the same width, adding further complications.

If we apply density padding to a component around an icon with built-in margin we’re going to get more visual padding than we intended.

Font size is commonly picked for readability and balance based on cap height:

Font showing excess margin spacing

Seen here, Arial at 28px has an actual cap height of only 20px.

The default line height at this size is 32px, meaning 12px (32 - 20) of the font size is for descenders, high ascenders, or leading between lines.

Like the icons, if we want to pad around text, the visual balance will be off because this font has an extra 6px top and bottom without visible content.

Padding with and without borders

The density system includes a helper for border width.

Borders may not always be visible, but they always take space. It’s good planning for components to have borders for increased or high contrast scenarios.

Calculating the total visual spacing.

The calculation for the total configured outer spacing size is available as:

These tokens represents the formula for design unit size, base density multipliers, parent element density adjustments, and local density adjustments.

Adjusting for border width.

Any component with a border width should register that with the density system, then use the border-adjusted values for padding:

These tokens represent the ‘Value’ adjusted for the provided border width.

Looking again at the outline button that was larger than expected:

A button with a border

The button styling would be:

.control {
    border: calc(${strokeWidth} * 1px) solid #C9C9C9; /* For css style */
    padding: calc(${densityComponentVerticalOuterPadding} * 1px) calc(${densityComponentHorizontalOuterPadding} * 1px);
}

Assuming strokeWidth is 2, this results in 6px vertical outer padding + 2px border, totaling 8px or 2 x 4px design units.

Adjusting for content margins

Handling child components or content that has included margins.

While we recommend not adding margins to components, some content can’t avoid them and we need a way to achieve consistent layout.

A practical use for negative margins

When building a component there’s no way to know how it’s going to be used or what content it will have. Therefore, to maintain design consistency, the content needs to take care of itself. For example, a button may contain text and/or an icon. The text doesn’t have margin on its left or right, but the icon might. For the button to be consistent in both uses, the margin must be trimmed from the icon, resulting in a smaller content box.

Caution for super tight density settings.

If you have content like an icon where the margin has been removed, but the icon shape extends into that margin, if there is no padding within the component, that content will interfere with the border or potentially spill out.

If you have icons like this, make sure density can never go so tight where this might happen. This will be rare.

Building on the previous example, let’s add an icon and remove its margin:

A button with an icon and text

Zoomed in we can see that the button has the expected padding and the “+” on the icon extends outside of the 16px bounding box to maintain visual balance.

Previous button zoomed in for detail

Note that because the backpack is tall, the space to the left and right is normal and is not the margin. The trimmed margin is the darker blue area.

Font and localization issues

Font metrics are not designed equally.

Any design relying on font size must account for browser layout rules.

Ascender, descender, and line height.

Since density applies to the empty space inside a component, the empty space around text is also important. This is a complex relationship of font metrics that can vary substantially between different type faces, however, the visible density of an experience should be the same regardless of the font being used.

Leading vs. line height.

Historically, “leading” was applied between lines of type, making “line height” the font height plus the space beneath the line. The web splits this leading in half and applies equal amounts to the top and bottom. This is why different font faces or sizes are difficult to vertically align.

A sampling of fonts at 28px with default line height (purple), baseline aligned:

Multiple fonts showing alignment differences

Collisions and clipping happen between lines when line height is too small, possibly even with the default value:

Same fonts, showing collisions when two lines

Controlling font height

Strategies for using line height.

A default or custom “line height” is relevant for multiple lines of text. Most text in a UI is only one line though. Line height is not useful in this context because tracking for readability and collisions are not an issue. The concern instead is how much spacing type should have around and between other elements.

Type ramp visual height.

The adaptive density system introduces a “visual” height for use in these common single-line components. We’ve looked at how applying space around an icon requires removing any margins, and this is the same idea for text.

Text and icons are often balanced within a UI, and the easiest way to achieve this is to set the visual height to match your icon height. This way icon-only, text-only, and icon + text buttons will all be the same height using the same density.

Further offsets and specific placement.

Most fonts used for UI have a reasonable and centered line height. Building on the rationale that the component can’t adjust for the content, here again the content needs to change. Perfect alignment of troublesome fonts requires effort.

For example, if you want to use the Homemade Apple font in a button, and you want it aligned with an icon, wrap it in a span and apply a negative bottom margin to pull the line down. The same caution about tight density applies here.

With the visual height set to match the icon height (without margins), the same density padding will work for all button configurations.

Icon-only, text-only, and icon plus text button examples

In this example, the line height for multi-line text is 20px. This happens to match the unadjusted size of this icon, but for the density measurements to make sense and not introduce extra spacing, the margins need to be trimmed from both, resulting in 16px content in this example.

Button example with off-center font

For Homemade Apple, the 16px visual line height places the text higher in the button. To pull it down the negative bottom margin must be measured visually, just as with selecting the line height for multi-line text.

Other uses for content margins

Applying margin to content elements to help with layout.

The previous example of negative margins was to trim built-in margin from content elements like icons or text. Applying a negative margin may also be useful in content layout scenarios to pull specific elements out.

Pulling out buttons like close “X”.

A flyout or dialog typically has larger margins for the primary content to leave a border around the edge. One way to achieve this may be to position the button and pull it out from the content area with negative margin.

For instance, to maintain a smaller margin around the button, remove the entire dialog padding and add one design unit back.

Adding a full-height button to an input.

In the example removing the margin from an icon, that was intended to layout inline with text in an input or button. A similar common example is where a button fully extends into an input’s padding to appear as part of it.

Since the input doesn’t know what will be provided for content, the button needs the styling. In this case it can simply invert the css variable values already calculated for padding in the input.

Components using negative margins

Analysis of scaling

Density vs. scaling

Scaling changes the size of the content.

This can happen through zooming or manually applying different size decisions, like the next size up or down in the type ramp.

Scaling is provided by the host environment.

Platforms provide support for universal scaling across an experience. Operating systems provide display scaling and browsers provide zooming for a page.

Designing for bespoke sizes doesn’t scale well.

To implement component sizing consistently in a design system, each size would need to be designed for each component, which doesn’t scale well in terms of consistency and effort.

This model is also difficult to extend for in-between or additional sizes, resulting in sizes like “Medium-Large” or “Largest” if using the classic t-shirt size model.

Different sized buttons scaled to show they don't adjust "density"

Display scaling

Setting size at the device level.

Most platforms provide a way for someone to globally set their size preference.

Overlapping, nonstandard, inconsistent.

This provides needed functionality, but it’s not the same across platforms and not easy to describe or design for. It does however imply the different concerns of component size (for touch usability) and text size (for readability).

One of the downsides of scaling is antialiasing and loss of definition on anything that’s no longer on-pixel like icons, borders, and divider lines.

The need for a “base” font size.

We can address these two issues independently.

The first priority is to set a font size that’s comfortable to read. This is equivalent to the “body” text in a document or the intent of “1 rem” on the web.

Now the density will adjust component sizes without making text too large or too small, and will preserve detail in icons and lines.

Windows 11 display scaling

Windows 11 display scaling settings

Android settings for font and display size

Android settings pages

“Font size” adjusts some “body” content only: Header text only changes when “Display size” is changed.

iOS settings for zoom and text size

iOS settings pages

“Zoomed” works like “Scale” in Windows, effectively reducing the canvas size to 85% then stretching to the display.

“Text Size” scales the header, unlike on Android.

Further additions

Size-based layout adjustments

Combining low-level density settings with alternate layouts.

The foundation of the density systems allows for complete control over the spacing related to components and layouts. This is often paired with modifications to a component that affect which elements are visible or the way they are arranged.

Responsive layout without changing screen size.

One way to think of this is similar to the affordances made for devices of a different size. We’re used to a more tight and vertical layout on a mobile device compared to a desktop. Layouts are adjust and sometimes content is pared down to present only the most relevant information. This is another technique that can be used to adjust density without changing the size of the content.

Applied to items in a list.

A great example for this capability is for items in a list. Many email apps offer the choice of a tradeoff between how many items you can see and how much information you can see about each item.

Cards at different sizes

Three sizes of items that could appear in a list, with different amounts of information or components based on the overall size of the component.

ben-girardet commented 2 years ago

Great job @bheston ! I was looking forward to this new density story and I'm not disappointed. It will be very helpful in many situations. I remember that we also started to discuss the density story in the context of rounding corners for buttons.

I guess, in order to include this matter in the density story, my first question is about the corner radius Design Tokens ? I remember that first it was called cornerRadius and now it's controlCornerRadius, described as "Sets the corner radius used by controls with backplates" in the doc. Do we assume that controlCornerRadius will be used for all buttons, inputs, tabs, etc ? Or are there going to be new (more specific) corner radius tokens ?

Now regarding the risk of border collision due to a large radius with small density. I don't think components should by default include a mathematical check to avoid such collision. It seems a little overkill due to the rare occasions that this might happen.

With the great documentation that you've put up in this RFC, I wonder if a comment about this potential issue should simply be stated with an exemple of solution provided.

And this is were I'm curious. How one would implement such a solution. Would it be by extending the styles at registration ?

provideFASTDesignSystem()
    .register(
        fastButton({
          styles: (ctx, def) => css`
            ${buttonStyles(ctx, def as any)}
            ${fixedDensity}
          `
        }),

or could it be achieved by "extending" part of the design tokens themselves ? What do you think ?

EisenbergEffect commented 2 years ago

I finally got a chance to read through it 😄 @bheston What are our next steps here?

bheston commented 2 years ago

Thanks @ben-girardet! I'm glad to hear this idea sounds good to you.

Currently there is the one corner radius token that's used for most controls. As you've identified, this is not entirely flexible for all designs. For instance, it doesn't account for a Switch either following other inputs or being pill-shaped.

This leads to the next RFC I plan to work on, which starts to describe what I've been referring to as "modular styling" (or possibly "inversion of design", publishing both here so I get the 'coining' credit on them). In the case of the button vs. input example, it's really more about scoping a design token value. Syntax aside, something like:

This helps keep the token count down as well as allows for easier unified or diverging design decisions. We've intentionally stayed away from permutating all the design tokens for each control because it doesn't scale well.

Also glad to hear your thoughts about default handling of corner radius. I do still think it's a great idea, and I would publish a sample like you mention. Always hard to get to everything, but I'd love to come up with a lot more quick samples on how to adjust the design system to individual needs.

In this case I would probably redefine the design token. From the TextField and Button perspective it's still just the controlCornerRadius, so the token and style doesn't have to change, only the token value. This could be (following the formula your PR used):

densityComponentHorizontalOuterValue.withDefault(
    (element: HTMLElement) =>
        (10 + Math.max(0, controlCornerRadius.getValueFor(element) - 10) // <- This line added
            densityComponentHorizontalOuterUnits.getValueFor(element) +
            densityComponentHorizontalAdjustmentUnitsCumulative.getValueFor(element)) *
        designUnit.getValueFor(element)
);

Which adds up to 10px depending on the value of the corner radius token. I just added the first line of the formula here to the existing token definition.

chrisdholt commented 2 years ago

The primary question I have here is in regards to layout density tokens - I want to confirm that while we may provide those as a convenience for folks building sites, applications, etc with FAST, we are NOT applying those to components themselves.

bheston commented 2 years ago

The primary question I have here is in regards to layout density tokens - I want to confirm that while we may provide those as a convenience for folks building sites, applications, etc with FAST, we are NOT applying those to components themselves.

The intent of the layout tokens is to apply in places where a group of other components is organized. In the illustrations I show a toolbar and a listbox and the tokens are applied between items or around the edges. The goal is to have individual knobs for "controls" and "groups of controls" or other layout-type things.

If your concern is around the terminology of "component" density because everything is actually a "component" in this world, I think that's a fair point and possibly we should have another name instead of "component". From an intent perspective I picture the current "component" tokens applied to what we may otherwise refer to as "controls" like buttons, radios, items/options, inputs, tabs, etc.

"Layout" would be for lists, menus, trees. I think those components would get density applied because that is already part of the styling.

Other layout components like card, dialog, etc. don't have styles for this spacing primarily because for implementation they are best applied somewhere else. We would not add density in these cases, but the tokens would be available for the consumer to apply.

Does that address the root of your concern?

bheston commented 2 years ago

I'm open to naming that's more clear or accurate. Now is the time. :)

janechu commented 1 month ago

Unfortunately @microsoft/fast-components has been deprecated for some time.