Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
argyleink opened this issue Sep 7, 2022 · 38 comments

Comments

@argyleink
Copy link
Contributor

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
Copy link
Contributor Author

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

@CaptainCodeman
Copy link

CaptainCodeman commented Sep 7, 2022

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

@flackr
Copy link
Contributor

flackr commented Sep 7, 2022

Or auto, since 0 is the initial value.

@bramus
Copy link
Contributor

bramus commented Sep 7, 2022

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
Copy link
Contributor Author

argyleink commented Sep 7, 2022

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
Copy link

mayank99 commented Sep 7, 2022

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
Copy link
Contributor

flackr commented Sep 7, 2022

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
Copy link
Member

dbaron commented Sep 7, 2022

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
Copy link

a-type commented Sep 7, 2022

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
Copy link

Afif13 commented Sep 7, 2022

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
Copy link
Member

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
Copy link
Contributor

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
Copy link

dutchcelt commented Sep 8, 2022

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
Copy link

.parent {
  border-radius: 24px;
  padding: 10px;
}

.child {
  border-radius: auto;
}

@equinusocio
Copy link

equinusocio commented Sep 9, 2022

@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
Copy link
Contributor

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
Copy link

equinusocio commented Sep 9, 2022

@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
Copy link

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
Copy link

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
Copy link

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:

  • Getting a property value from a "parent". inherit() seem relevant
  • Exactly what constitutes a parent (as @dbaron brings us). Maybe that could be a container?

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
Copy link
Contributor

una commented Mar 6, 2023

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
Copy link
Contributor Author

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
Copy link

@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
Copy link
Contributor

una commented Mar 10, 2023

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

@SebastianZ SebastianZ changed the title New border-radius value for perfectly matching nested radii [css-borders-4] New border-radius value for perfectly matching nested radii Jul 27, 2023
@equinusocio
Copy link

equinusocio commented Oct 9, 2023

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
Copy link

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
Copy link

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
Copy link

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
Copy link

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
Copy link

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
Copy link

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
Copy link

ddamato commented Oct 10, 2023

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

  • I think it makes sense for the inner radius to be the driver of overall nested rounding for the rest of the page as the inner-most radius should be the smallest amount of rounding we'd want to see. If the parent drives the radius, it's possible we end with no rounding at the deepest levels. This means that smaller elements drive the rounding of larger containers.
  • This would also mean that the rounding of the outer element would depend on the existence of the smaller element. If we had several similar cards, but only one had a rounded button inside, how would we determine what the rounding of the other cards would be to match? Virtual elements?
  • Now, if you believe that the outer radius is the driver, this also may cause elements to never receive rounding. You might imagine a straight-cornered navigation bar which expects a rounded button to match the rest of the rounding in the page, but it never receives the rounding values because none of the ancestors are rounded.

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
Copy link
Member

LeaVerou commented Oct 10, 2023

First thoughts:

  • My gut reaction is that I'm not sure the complexity this adds is warranted by the % of use cases it actually solves (many use cases not solved have already been mentioned above). My personal preference would be to make it easy for authors to dynamically get the metrics needed to craft a reactive expression that resolves to the right radius.
  • For this to work in the general case (unequal paddings, unequal border widths), it needs to be able to resolve to elliptical radii, so for border-*-radius longhands it would need to apply to the whole value. It's unclear how it can be used in the border-radius shorthand, since horizontal and vertical radii for the same corner is separated (an unfortunate design decision…). Is it only going to be allowed in the non-slash syntax?
  • Note that in addition to parent border width and padding, it also needs to take target margin into account.

@davidleininger
Copy link

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
Copy link

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)
<parent>
  <child></child>
</parent>
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)
@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
Copy link
Contributor

yisibl commented Sep 2, 2024

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
Copy link
Contributor

@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
Copy link
Contributor

yisibl commented Sep 2, 2024

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests