w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.47k stars 659 forks source link

[css-borders-4] New `border-radius` value for perfectly matching nested radii #7707

Open argyleink opened 2 years ago

argyleink commented 2 years ago

The Problem:

The majority of implementations where nested border radius are used; are asymmetrical and imperfect. The imperfect solution is easy to do, while the perfect solution is harder.

.card {
  border-radius: 24px;
}

.card picture {
  border-radius: 24px 24px 0 0;
}
Screen Shot 2022-09-07 at 9 15 06 AM


Proposed Solution:

A new border-radius value for nested elements, making symmetrical and perfectly matching nested radii easy. The math is handled by the browser when the keyword is used: parent-radius - parent-padding.

.card {
  border-radius: 24px;
}

.card picture {
  border-radius: match-nearest-parent match-nearest-parent 0 0;
}
Screen Shot 2022-09-07 at 9 15 11 AM

Corners compared

Below is a focused screenshot of the corners for comparison. The border radius on the left has a wobble to it, as the curves don't match. The border radius on the right does match and follows the same curve for a nice perfect finish. Which do you want in your design?

Screen Shot 2022-09-07 at 9 11 25 AM

Conclusion

While it's relatively trivial to do the math inside calc() with custom properties passed down to children, it's not easy or intuitive. By adding an additional border-radius value that does the effect easily, we'll see better designs because the nice choice is easy and built-in.

Demo source: https://codepen.io/argyleink/pen/LYmpqMB

argyleink commented 2 years ago

match-nearest-parent up for bikeshedding! could be just match?

CaptainCodeman commented 2 years ago

match sounds like it means "the same value as", maybe something like parallel describes it better? (equidistant, aligned)

flackr commented 2 years ago

Or auto, since 0 is the initial value.

bramus commented 2 years ago

What if you have extra elements in between .card and picture? Should match-nearest-parent –or whatever the keyword will be– look at its actual direct parent, or at the nearest parent that has rounded corners?

argyleink commented 2 years ago

What if you have extra elements in between .card and picture? Should match-nearest-parent –or whatever the keyword will be– look at its actual direct parent, or at the nearest parent that has rounded corners?

I was assuming the nearest parent with a rounded corner, hence the long winded name to try and be explicit about what you'll get: "match the nearest parent with a border radius"

[edit] It's also not just any nearest parent rounded corner, it's the same corner. so if all corners in the parent have different radii, each corner of the nested radii would match their respective corner.

mayank99 commented 2 years ago

match sounds like it means "the same value as"

I agree, the word match is super confusing, and parent makes it even more so.

Maybe something that avoids both of those terms, e.g. nested or auto-adjust.

But we could bikeshed this all day 😄

flackr commented 2 years ago

Would the new property know which corners align with the parent? E.g. in your example is it necessary to specify which corners align, or could you just do this:

.card {
  border-radius: 24px;
}

.card picture {
  border-radius: match-nearest-parent;
}

I wonder if only referencing the immediate parent would help avoid corner cases (pun intended). E.g. it feels a bit weird that going from border-radius: 0 to border-radius: 1px on an intermediate node could change a descendant's clip from a large radius to a small one.

dbaron commented 2 years ago

I also don't like the phrase "nearest parent", since a node has either one parent or none. I think the idea is to match the nearest ancestor that has a border-radius... but I also agree with @flackr's point that maybe just looking at the parent is the right thing.

a-type commented 2 years ago

Love this idea! May I suggest one more use case to consider - rounded content aligned by position within a larger bordered parent, like inline buttons inside text fields or buttons aligned to corners of much larger areas:

image

To be clear - if the algorithm was naively matching corner radii 1:1 in the case above, the three non-aligned corners of the child button would have near-0 radii because of how far they are from their counterparts.

In this case you'd want all corners to match the radius of the nearest aligned corner. I think this behavior would probably be too magic to do in one keyword. Instead it might be nice to have the option to target a specific parent corner for each corner individually. As a sketch...

.code-example > button {
  border-top-left-radius: align ancestor-top-right;
  border-top-right-radius: align ancestor-top-right;
  border-bottom-left-radius: align ancestor-top-right;
  border-bottom-right-radius: align ancestor-top-right;
}

The use case may be too particular to support directly like this, just wanted to surface it.

Afif13 commented 2 years ago

I think we all agree about having something easy to use but I can see a lot of cases where this can be tricky. We are talking about padding but what about margin applied to the child element or an inset applied to a positioned element or an element having a width/height equal to, for example, 90% and is centered (we will have 10% of margin that we need to consider)

I can also see the case where the padding is not equal on all the sides so we should think about how the keyword should behave in these cases.

What if instead of defining the value for radius we define a value to get the "distance" between an element and its containing block?

For example: 1dt (distance top) will be equal to the top distance between the element its containing block. That distance can be a padding, margin or whatever. We all needed such value one day and we use JS to get it (https://api.jquery.com/position/)

knowing such values (they will be 4) we can use them to define the radius like we want

example

.parent {
  border-radius: 24px 24px 0 0;
}
.child {
  border-radius: calc(24px - min(1dl,1dt)) calc(24px - min(1dr,1dt)) 0 0; 
}

I know it look a bit complex but the logic is to get the smallest distance between left and top when defined the top-left radius so that even if the padding,margin is not the same we still get a nice radius that match to the parent one

image


I also think such values can be useful in other situations as well.

tabatkins commented 2 years ago

but I also agree with @flackr's point that maybe just looking at the parent is the right thing.

Yes, there are potentially a large range of possible situations that fit this pattern (multiple wrappers between you and the border-radius container, you using margin vs the container using padding, etc), but I don't think it's reasonable to try and address all of them.

So a simple "look at parent, subtract parent's padding, floor at 0" seems like it hits the 80% case in a super simple, easy to explain fashion. (And hopefully much more easily implementable than handling the wider range of situations.)

Loirooriol commented 2 years ago

Covering all cases correctly seems tricky, e.g. the .card picture could have margins, it could be an inline-block centered with text-align or have surrounding content, etc. Too many edge cases where this will still look wrong.

But challenging the premise, I think the solution that you actually want is:

.card {
  border-radius: 24px;
  overflow: clip;
  overflow-clip-margin: content-box;
}

with .card picture and .card footer just having the default border-radius: 0px.

Demo: https://software.hixie.ch/utilities/js/live-dom-viewer/saved/10669 (works on Chromium)

dutchcelt commented 2 years ago

This is tricky but I really like the intrinsic idea here. Maybe utilizing containers might be an option? This way we can declare the path to follow.

.panel {
  container: panel / size;
}
.content {
  border-radius: follow follow 0 0 / panel; /* default is parent */
}
jonsherrard commented 2 years ago
.parent {
  border-radius: 24px;
  padding: 10px;
}

.child {
  border-radius: auto;
}
equinusocio commented 2 years ago

@argyleink Just a note about the calculation. I think we have to take in consideration the optical perception. The current math need to add a smaller % of the outer radius to fix the optical issue. I made a live example:

https://codepen.io/equinusocio/pen/PoeNPdP?editors=1100

CleanShot 2022-09-09 at 08 46 00

Loirooriol commented 2 years ago

I don't like auto for this. If we had border-radius: auto, my intuition would be that it's the initial value, and it typically resolves to 0, but allows the UA to choose another value in certain cases. For example, -webkit-appearance: -apple-pay-button are rounded by default in WebKit, but border-radius: 0 makes the corners sharp. Currently this is done using some internal magic, and IMO an auto keyword would be more suited for these kind of things (though probably not worth doing).

Something like fit-parent or such seems way clearer to me.

Though as I said, overflow: clip; overflow-clip-margin: content-box seems a better solution in most cases.

@equinusocio If you want to change how the radius of the padding edge is calculated, please file another issue.

equinusocio commented 2 years ago

@equinusocio If you want to change how the radius of the padding edge is calculated, please file another issue.

Nope, as shown in the pen, guess is related to this issue.

"The math is handled by the browser when the keyword is used"

dutchcelt commented 2 years ago

Though as I said, overflow: clip; overflow-clip-margin: content-box seems a better solution in most cases.

For visual parity you could just use a thick border, which might even trump the overflow clip approach. There is a common use case where you would have an element partially moved outside the card container to highlight it. Like so: https://codepen.io/dutchcelt/pen/ExLKZGe

The thick border is messy and overflow clip is a bit too restrictive. I think the case to have a simple unintrusive solution for this is clear.

.panel {
  border-radius: 24px;
  container: panel / size;
}
.content {
  border-radius: follow follow 0 0; /* default is parent */
  border-radius-target:  panel; /* could be child element */
}

I've suggested follow, but fit-parent or nearest-parent/nearest-child would also work well.

DarkWiiPlayer commented 2 years ago

I would suggest concentric as a name, as that seems to be the intended effect here.


Ans since I'm already leaving a comment, I might as well go crazy and suggest some feature-creep:

.outer {
   border-radius: 1em;
   padding: 10px;
}
.between {
   padding: 10px;
}
.inner {
   border-radius: concentric(.outer); /* Similar to JS "nearest" function */
}

With the HTML nested like this .outer > .between > .inner


And also, this assumes that the corners of the outer and inner radius are on a 45 degree line, which might not be the case when vertical and horizontal padding.


And last but not least, instead of handling this as a combination of paddings and margins, which fails to consider many other attributes that can shift the position of an object, wouldn't it make more sense to simply look at the X and Y positions of both corners and calculate the absolute distance like that?

justinfagnani commented 1 year ago

I've been thinking about how to solve a separate issue of allowing some elements to ignore the padding of a container in order to make full-bleed elements (images in cards, highlight of list-items, etc.) and I wonder if there some shared primitives here in terms of getting a parent's properties without custom variables in order to make the calc() easier to use.

I think there are two things possibly shared:

So maybe something similar in spirit to this could work:

.card {
  container: card / normal;
  border-radius: 24px;
}

@container card style(border-radius: /* unsure what goes here */) {
  .card picture {
    /* this isn't actually right because inherit() will produce a serialized value that won't work in calc() */
    border-radius: calc(inherit(border-radius) - inherit(padding));
  }
}

This might not be workable. The difficulty of doing math on multi-valued properties like border-*-radius might push towards a built-in keyword, though it'd be cool to be able to distribute the calc over the individual values, or pick apart the values into a complex rule and spread that in with a mixin.

una commented 1 year ago

This is similar to the discussion around currentBackgroundColor. match-nearest-parent, or fit-parent, or even parallel might not be what you want, depending on the structure of your HTML. You might want to apply this value on something that isn't an immediate child, for example:

<div class="card">
  <div class="extra-layout-element-for-card-that-has-no-border-radius">
    <picture>
      <img ... >
    </picture>
    <footer>element that would get the radius, but immediate parent doesn't have border-radius</footer>
  </div>
</div>

+1 to @justinfagnani - I think container queries would be useful here, and specifically matching a container's style value. That way, you can specify what value you want to use from what specific parent. But to his last question, I think something more reusable like inherit() is better than a built-in keyword, since its more scalable. In the same way we would look to inherit the border-radius and padding to calculate the border-radius of the child element, we could use the background-color, border-width (which could also affect the calculation), or any other non-inheritable property.

argyleink commented 1 year ago

agree, match-nearest-parent is weak compared to specifying which container to compute against 👍🏻

could combine @justinfagnani and @DarkWiiPlayer) suggestions?

.card {
  container: card / normal;
  border-radius: 24px;
}

.card picture {
  border-radius: concentric(card) concentric(card) 0 0;
}

if authors don't specify the container, it's the nearest container by default:

.card picture:only-child {
  border-radius: concentric();
}
justinfagnani commented 1 year ago

@una

I think something more reusable like inherit() is better than a built-in keyword, since its more scalable.

In spirit, I agree. I just wonder how you actually structure the calculation. Given that border-radius is a shorthand, and even the individual properties are multi-valued, what so the expressions look like?

Can we possibly make is so that conceptually border-radius - padding works?

una commented 1 year ago

@justinfagnani, despite it being a shorthand, you could inherit the entire thing and apply the subtracted padding to each value.

equinusocio commented 1 year ago

agree, match-nearest-parent is weak compared to specifying which container to compute against 👍🏻

could combine @justinfagnani and @DarkWiiPlayer) suggestions?


.card {

  container: card / normal;

  border-radius: 24px;

}

.card picture {

  border-radius: concentric(card) concentric(card) 0 0;

}

if authors don't specify the container, it's the nearest container by default:


.card picture:only-child {

  border-radius: concentric();

}

Does it work if there are many wrapping elements between .card and the picture? I mean there are common situations where you don't have control over where the element with concentric() is placed. And the nearest parent may not have any border-radius set.

Is that bad if the keyword/function behaves like the position absolute (depending on the first position relative ancestor), in this case depending on to the first ancestor with border-radius?

happy2deepak commented 1 year ago

only issue with the basic math approach of outer/inner radius is that it does not takes into consideration the border-width of any of the elements. And this would create a unexpected behaviour.

alex-krasikau commented 1 year ago

match sounds like it means "the same value as", maybe something like parallel describes it better? (equidistant, aligned)

inherit means use the same value, match sounds just right IMO.

brandonmcconnell commented 1 year ago

Would concentric(card) match the ancestor .card by its class? That's not entirely clear to me from the syntax.

Any chance we could open this up to full selector matching, like concentric(.card), concentric(#some-id), or even a more generic concentric(div)?

DarkWiiPlayer commented 1 year ago

Would concentric(card) match the ancestor .card by its class? That's not entirely clear to me from the syntax.

Any chance we could open this up to full selector matching, like concentric(.card), concentric(#some-id), or even a more generic concentric(div)?

Yes, my idea when I proposed that syntax was to have full selector support, but apparently that idea got lost somewhere along the thread.

I don't see any good reason not to make this as generic as possible by simply allowing a selector for the browser to use the first matching ancestor.

itsdonnix commented 1 year ago

I prefer auto as the value to it. Make more sense and even the solutions are out there but it will be great addition to CSS.

equinusocio commented 1 year ago

Is that bad if the keyword/function behaves like the position absolute (depending on the first relative positioned ancestor), in this case depending on the first ancestor with border-radius set?

Any strong opinions about why it can't behave like other props?

ddamato commented 1 year ago

I've thought a lot about how this might exist systematically, I put my thoughts in this post. But here's the finer points:

Ultimately, I think @tabatkins comment about not addressing all the scenarios is the best path forward. It just means we need to be clear about what this is expected to solve and what it will not.

LeaVerou commented 1 year ago

First thoughts:

davidleininger commented 1 year ago

I agree with @ddamato

Ultimately, I think @tabatkins comment about not addressing all the scenarios is the best path forward. It just means we need to be clear about what this is expected to solve and what it will not.

Reading through all of this, I keep thinking the same thing, how would the people I work with use this? I work with a lot of people who would never know how to write the calc function to handle the basic functionality. Most of the time they wouldn't really look for a solution to copy and paste in, they would just try to guess numbers that look "correct" or use what the designer provided. That is a very limited and rigid solution. If there was a value they could add easily to handle most cases, I think they would reach for it a lot.

There are properties/values in CSS that only work when the other elements have the correct values as well. I think it's worth addressing the simple use cases with a value that is easy to understand. Then, if the author needed to do something more complex, they would have to write all of the radii independently for the specific use case.

brandonmcconnell commented 1 year ago

Just a thought, as the complexity seems to be building as @LeaVerou pointed out— could this be a good use case for declarative functions, if all these non-% radii use cases are essentially variable interpolation?

Here is a nested border-radius example using different values for both the border-radius at each corner, as well as padding per each side: CodePen.

(expand/collapse source)
```html ``` ```css parent { --br-tl: 30px; --br-tr: 48px; --br-br: 82px; --br-bl: 130px; --p-t: 20px; --p-b: 10px; --p-r: 26px; --p-l: 44px; border-radius: var(--br-tl) var(--br-tr) var(--br-br) var(--br-bl); padding: var(--p-t) var(--p-r) var(--p-b) var(--p-l); } child { border-radius: calc(var(--br-tl) - var(--p-t)) calc(var(--br-tr) - var(--p-r)) calc(var(--br-br) - var(--p-b)) calc(var(--br-bl) - var(--p-l)); } ```

Using declarative functions with a new self() function to get computed values for other properties on the same element (think this in an element method in JS), the complex CSS could all be abstracted away like this:

(expand/collapse source)
```css @custom-function --get-nested-radius { result: calc(self(border-top-left-radius) - self(padding-top)) calc(self(border-top-right-radius) - self(padding-right)) calc(self(border-bottom-right-radius) - self(padding-bottom)) calc(self(border-bottom-left-radius) - self(padding-left)); } parent { border-radius: 30px 48px 82px 130px; padding: 20px 10px 26px 44px; --nested-radius: --get-nested-radius(); } child { border-radius: var(--nested-radius); } ```

I'm sure I'm missing some level of that calculation where the corner radius needs to account for the padding of both sides it touches and take a weighted average of the two, but this is essentially the idea.

I agree that having something like this built into the browser could be handy, but if there's any part of this that could potentially change based on use case, would it be better to leave that calculation up to the end user/dev?

Btw if there's a more accurate parent-directed formula to build the nested border-radius value than what I came up above, could someone please let me know what that would be? Thanks in advance 🙏🏼

yisibl commented 1 month ago

How do we change the internal radius if there is only one element?

Maybe we need to introduce a new inner-border-radius property? https://codepen.io/yisi/pen/BagqKNq?editors=0100 image

Loirooriol commented 1 month ago

@yisibl Use box-shadow? https://software.hixie.ch/utilities/js/live-dom-viewer/saved/13047

  margin-top: 50px;
  box-shadow: 0 -40px 0 10px var(--bd-color), 0 0 0 10px var(--bd-color);
  border-radius: 10px;
yisibl commented 1 month ago

@Loirooriol box-shadow does not produce a gradient border, which means it doesn't work with background-clip: border-area, which was just recently implemented in WebKit.