Skip to content

feat(header, footer, tab-bar): add scrollEffect prop#31263

Open
OS-susmitabhowmik wants to merge 47 commits into
nextfrom
ROU-12870-scroll-effect
Open

feat(header, footer, tab-bar): add scrollEffect prop#31263
OS-susmitabhowmik wants to merge 47 commits into
nextfrom
ROU-12870-scroll-effect

Conversation

@OS-susmitabhowmik

@OS-susmitabhowmik OS-susmitabhowmik commented Jul 10, 2026

Copy link
Copy Markdown

Issue number: resolves internal


What is the current behavior?

  • ion-header and ion-footer have a collapse prop for scroll-driven effects (condense, fade), but it only works on the iOS theme.
  • ion-tab-bar has an unreleased hideOnScroll boolean prop that only works on the Ionic theme with expand="compact".

What is the new behavior?

  • Adds a new scrollEffect string enum prop to ion-header, ion-footer, and ion-tab-bar.
  • scrollEffect="hide" slides the bar out of view when scrolling down and back in when scrolling up.
  • scrollEffect="condense" and scrollEffect="fade" on ion-header/ion-footer replace the existing collapse values and work across all themes.
  • Deprecates the collapse prop on ion-header and ion-footer with a console warning directing developers to use scrollEffect.
  • Removes the unreleased hideOnScroll prop from ion-tab-bar.
  • When ion-tab-bar is nested inside ion-footer, the footer owns the hide animation to prevent double-animation

Does this introduce a breaking change?

  • Yes
  • No

Other information

Relevant Test Pages:

Header

Footer

Tab Bar

@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ionic-framework Ready Ready Preview, Comment Jul 17, 2026 11:50pm

Request Review

@github-actions github-actions Bot added package: core @ionic/core package package: angular @ionic/angular package package: vue @ionic/vue package package: react @ionic/react package labels Jul 10, 2026
@OS-susmitabhowmik
OS-susmitabhowmik marked this pull request as ready for review July 13, 2026 17:01
@OS-susmitabhowmik
OS-susmitabhowmik requested a review from a team as a code owner July 13, 2026 17:01
@OS-susmitabhowmik
OS-susmitabhowmik requested a review from ShaneK July 13, 2026 17:01

@ShaneK ShaneK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, great work on this! I highlighted a few issues I found, some of them aren't as important as others, but still

Comment thread core/src/components/header/header.tsx
Comment thread core/src/utils/content/index.ts Outdated
Comment thread core/src/components/footer/footer.tsx
Comment thread core/src/components/header/header.tsx
Comment thread core/src/components/tab-bar/tab-bar.tsx
Comment thread core/src/components/footer/footer.tsx
Comment thread core/src/components/tab-bar/tab-bar.tsx
Comment thread core/src/utils/scroll-hide-controller.ts

@ShaneK ShaneK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey! Thanks for fixing up those issues! I found some more things that were a bit concerning with this change, let me know if I'm off the mark on anything here!

Comment thread core/src/components/header/header.tsx
Comment thread core/src/components/header/header.tsx
Comment thread core/src/components/footer/footer.tsx
Comment thread core/src/components/tab-bar/tab-bar.tsx
Comment thread core/src/components/header/header.md.scss Outdated
Comment thread core/src/components/tab-bar/tab-bar.tsx
Comment thread core/src/utils/scroll-hide-controller.ts Outdated
Comment thread core/src/components/content/content.scss Outdated
Comment thread core/src/components/header/header.ios.scss
Comment thread core/src/components/tab-bar/tab-bar.tsx

@ShaneK ShaneK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for grinding through all of this, most of the last round holds up and I verified it. Found a few more edges though, including one where the main header disappears with no large title (reproduced it inline). Nothing structural, mostly tightening up the cases we keep uncovering. Let me know if I'm off base on any of these!

* collapse into the main header toolbar.
*/
const hasLargeTitle = this.el.querySelector('ion-title[size="large"]') !== null;
if (!hasLargeTitle) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pulled the branch and this one renders the page with no visible header on both md and ios. When a condense header has a plain (non-large) title, the header-collapse-condense class still gets applied in render(), but setupCondenseHeader bails on this large-title guard before it reaches the code that adds header-collapse-main to the primary header. So the :has() ghost-hide rule in header.common.scss pins the main header at opacity: 0 and nothing ever releases it.

Repro is /header/test/scroll-effect-condense-no-large-title: the main header computes to opacity: 0 and never gets header-collapse-main. The new e2e only screenshots the condense header, so it doesn't catch the main one vanishing.

I think we either gate the header-collapse-condense class on a large title too, or keep assigning header-collapse-main when we bail here. Worth a test that asserts the main header stays visible in the no-large-title case.

* `"condense"` collapses the large title into the main toolbar on scroll.
* `"fade"` fades the toolbar background on scroll.
*/
@Prop() scrollEffect?: HeaderScrollEffect;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since scrollEffect isn't reflected, this breaks down under framework property binding. I set the prop as a property (the way the Angular/Vue/React proxies bind it) and confirmed no scroll-effect attribute lands on the host, so el.matches('[scroll-effect="condense"]') is false and the class only appears after render.

That trips up the fade setup's querySelector('...ion-header[scroll-effect="condense"]') on line 161, which has no class fallback, so a fade header paired with a property-bound condense sibling loses the coordination and fades in immediately. The :has() flash guard has the same gap in the pre-hydration window.

Adding reflect: true here would make both selectors robust and match the comment in header.common.scss that already assumes it's reflected. Flagging in case reflection was intended and just got missed.

React.PropsWithoutRef<StencilReactExternalProps<PropType, ElementType>>
>
);
return React.forwardRef(forwardRef);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks unrelated to the scroll effect work, and it reverts the explicit forwardRef cast (plus the comment explaining it) that's still on next. Feels like a bad merge? It re-widens the type the cast was guarding. Could we drop this hunk unless it's intentional?

this.el.setAttribute('aria-hidden', 'true');
} else {
this.el.removeAttribute('inert');
this.el.removeAttribute('aria-hidden');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The commit says this unifies aria-hidden under one imperative writer, but there are still two paths that disagree. The keyboard callback ORs the keyboard and scroll conditions, while setHidden(false) here clears aria-hidden and inert unconditionally.

I reproduced it: with the keyboard open on a bottom tab bar (aria-hidden correctly true), scrolling down then back to the top fires setHidden(false), which strips both aria-hidden and inert while the keyboard is still open, so the off-screen tab bar gets exposed to AT again. Could setHidden OR in the keyboard state too, or route both writers through one function?

const hasFade = effect === 'fade';

this.destroyCollapsibleHeader();
this.activeEffect = effect;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We set this.activeEffect = effect here before the setup actually runs, and the setup below is gated on contentEl (condense/fade have their own early returns too). If content mounts late (behind an *ngIf/v-if), the effect gets marked active with nothing wired up, and the effect === this.activeEffect guard above then short-circuits every later componentDidUpdate, so it never recovers.

I reproduced this with deferred content: the header never picks up content-header-hide-scroll-partner even after the content appears and the header re-renders, while the same setup with content present up front works fine. Same deal in footer.tsx. I think setting activeEffect only after setup succeeds would fix it.

await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0));
await page.locator('ion-header.header-scroll-hidden').waitFor();

await expect(header).toHaveClass(/header-scroll-hidden/);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hide effect is the headline of the PR but it's the one effect with no visual or computed-style coverage. These assert the class and inert but never that the bar actually moves. The condense tests have screenshots and the fade ones check --opacity-scale, so hide is the odd one out.

If a theme bundle ever dropped the transform rule, the bar would sit on screen but inert over the gap-compensated content, and every test here would still pass. Could we add one toHaveScreenshot() or a getComputedStyle(...).transform check in the hidden state? Applies to the footer and tab-bar hide specs too.

}
}

private setupScrollEffectHide = async (contentEl: HTMLElement) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit / future maintenance: the controller cleanly owns the scroll math, but the wiring around it (the promise-identity guard, the ResizeObserver, the --internal-*-hide-height read/write, the content partner-class toggling, and the teardown) is copy-pasted almost verbatim across header, footer, and tab-bar. A fix to the hide behavior has to land in three places now.

Since all three already share the controller, could we push that wiring into it (or a small partner helper that takes the css var + class names) so it lives once? Fine to leave for a follow-up though.

Comment on lines +286 to +288
// Named differently from header/footer's destroyCollapsible* because
// tab-bar only supports "hide", while header/footer handle multiple
// effects (hide, condense, fade) through one teardown method.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Named differently from header/footer's destroyCollapsible* because
// tab-bar only supports "hide", while header/footer handle multiple
// effects (hide, condense, fade) through one teardown method.

Nit: this reads more like PR context than something the code needs. The method name already says what it does, and it points at destroyCollapsible* in the other files which'll go stale if those get renamed. Up to you though!

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

Labels

package: angular @ionic/angular package package: core @ionic/core package package: react @ionic/react package package: vue @ionic/vue package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants